feat(automations): implement automation features and UI enhancements

- Added new automation-related routes and controllers for managing automations.
- Introduced automation nodes in the UI with distinct styles and interactions.
- Updated sidebar to include navigation for automations.
- Enhanced post creation logic to support automation metadata.
- Refactored content type and platform enums into types for better type safety.
- Added localization for automation-related terms in English, Spanish, and Portuguese.
- Improved error handling in various components to accommodate new features.
This commit is contained in:
Paulo Castellano 2026-05-24 09:17:19 -03:00
parent fc18f044af
commit b23ab0166e
160 changed files with 9562 additions and 27 deletions

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Automation;
use App\Enums\Automation\Status;
use App\Models\Automation;
class ActivateAutomation
{
public function __invoke(Automation $automation): Automation
{
$this->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'));
}
}
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Automation;
use App\Enums\Automation\Status;
use App\Models\Automation;
use App\Models\User;
use App\Models\Workspace;
class CreateAutomation
{
public function __invoke(Workspace $workspace, User $user, string $name): Automation
{
return Automation::create([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
'name' => $name,
'status' => Status::Draft,
'nodes' => [],
'connections' => [],
]);
}
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Automation;
use App\Enums\Automation\Status;
use App\Models\Automation;
class PauseAutomation
{
public function __invoke(Automation $automation): Automation
{
$automation->update([
'status' => Status::Paused,
'paused_at' => now(),
]);
return $automation;
}
}

View file

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Automation;
use App\Models\Automation;
use DomainException;
class UpdateAutomation
{
public function __invoke(Automation $automation, array $data): Automation
{
$this->detectCycles($data['nodes'] ?? [], $data['connections'] ?? []);
$automation->update([
'name' => $data['name'] ?? $automation->name,
'nodes' => $data['nodes'] ?? $automation->nodes,
'connections' => $data['connections'] ?? $automation->connections,
]);
return $automation->fresh();
}
private function detectCycles(array $nodes, array $connections): void
{
$adj = [];
foreach ($connections as $c) {
$adj[$c['source']][] = $c['target'];
}
/** @var array<string, string> $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;
}
}

View file

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Node;
use App\DataTransferObjects\Automation\NodeRunResult;
use App\Enums\Automation\Condition\Operator;
use App\Models\AutomationRun;
use App\Services\Automation\ExpressionResolver;
class RunConditionNode
{
private const MAX_REGEX_LENGTH = 200;
public function __construct(private ExpressionResolver $resolver) {}
public function __invoke(AutomationRun $run, array $config): NodeRunResult
{
$field = $this->resolver->resolve(data_get($config, 'field', ''), $run->context ?? []);
$operator = Operator::from(data_get($config, 'operator', 'equals'));
$value = (string) data_get($config, 'value', '');
$matched = match ($operator) {
Operator::Contains => str_contains($field, $value),
Operator::NotContains => ! str_contains($field, $value),
Operator::Equals => $field === $value,
Operator::NotEquals => $field !== $value,
Operator::Matches => $this->safeRegexMatch($value, $field),
Operator::GreaterThan => is_numeric($field) && is_numeric($value) && (float) $field > (float) $value,
Operator::LessThan => is_numeric($field) && is_numeric($value) && (float) $field < (float) $value,
};
return NodeRunResult::completed(
output: ['condition' => ['resolved_field' => $field, 'matched' => $matched]],
nextHandle: $matched ? 'yes' : 'no',
);
}
private function safeRegexMatch(string $pattern, string $subject): bool
{
if (strlen($pattern) > self::MAX_REGEX_LENGTH) {
return false;
}
$escaped = str_replace('~', '\~', $pattern);
$regex = "~{$escaped}~u";
try {
$result = @preg_match($regex, $subject);
} catch (\Throwable) {
return false;
}
if ($result === false || preg_last_error() !== PREG_NO_ERROR) {
return false;
}
return $result === 1;
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Node;
use App\DataTransferObjects\Automation\NodeRunResult;
use App\Models\AutomationRun;
class RunDelayNode
{
public function __invoke(AutomationRun $run, array $config): NodeRunResult
{
$duration = (int) ($config['duration'] ?? 0);
$unit = $config['unit'] ?? 'minutes';
$until = match ($unit) {
'minutes' => now()->addMinutes($duration),
'hours' => now()->addHours($duration),
'days' => now()->addDays($duration),
default => throw new \InvalidArgumentException("Unknown delay unit: {$unit}"),
};
return NodeRunResult::sleep($until);
}
}

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Node;
use App\DataTransferObjects\Automation\NodeRunResult;
use App\Models\AutomationRun;
class RunEndNode
{
public function __invoke(AutomationRun $run, array $config): NodeRunResult
{
$reason = data_get($config, 'reason');
return NodeRunResult::completed(output: [
'end' => [
'ended_at' => now()->toIso8601String(),
'reason' => $reason ?: null,
],
]);
}
}

View file

@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Node;
use App\DataTransferObjects\Automation\NodeRunResult;
use App\Enums\Automation\Run\Status as RunStatus;
use App\Jobs\Automation\ProcessAutomationNode;
use App\Models\AutomationNodeState;
use App\Models\AutomationRun;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use SimpleXMLElement;
use Throwable;
/**
* Fetches an RSS feed and progresses the run with the next-up unseen item.
*
* - Filters by a per-node watermark stored in `automation_node_states`, so the
* very first run on an old feed processes none of the historical items.
* - When the fetch returns N new items, the current run takes item[0]; the
* remaining N-1 items are spawned as sibling runs that resume at the node
* immediately after this Fetch (with `context.fetched` already populated),
* so each item ends up generating its own Post / Webhook / etc.
* - When the feed yields no new items, the result short-circuits via the
* `no_items` handle; if the user hasn't wired anything to it, the run
* completes silently (handled by AdvanceAutomationRun's default branch).
*/
class RunFetchRssNode
{
public function __invoke(AutomationRun $run, array $config): NodeRunResult
{
$feedUrl = (string) data_get($config, 'feed_url', '');
if ($feedUrl === '') {
return NodeRunResult::failed('Fetch RSS node missing feed_url.');
}
$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) {
$state->update(['data' => array_merge($state->data ?? [], [
'last_item_date' => $newestSeen->toIso8601String(),
])]);
}
if ($newItems === []) {
return NodeRunResult::completed(['fetch' => ['count' => 0]], nextHandle: 'no_items');
}
$first = array_shift($newItems);
if (! $run->is_dry_run) {
$this->spawnSiblings($run, $nodeId, $newItems);
}
return NodeRunResult::completed([
'fetch' => ['count' => count($newItems) + 1, 'spawned' => $run->is_dry_run ? 0 : count($newItems)],
'fetched' => $first,
]);
}
private function collectNewItems(SimpleXMLElement $xml, CarbonImmutable $watermark): array
{
$items = [];
$newestSeen = null;
foreach ($xml->channel->item ?? [] as $item) {
$key = (string) ($item->guid ?? $item->link);
if ($key === '') {
continue;
}
$pubDate = $this->parsePubDate((string) $item->pubDate);
if ($pubDate === null) {
continue;
}
if ($newestSeen === null || $pubDate->greaterThan($newestSeen)) {
$newestSeen = $pubDate;
}
if (! $pubDate->greaterThan($watermark)) {
continue;
}
$items[] = [
'_pubDate' => $pubDate,
'key' => $key,
'title' => (string) $item->title,
'link' => (string) $item->link,
'description' => (string) $item->description,
'pubDate' => (string) $item->pubDate,
];
}
// Process oldest-first so siblings inherit a stable order matching feed chronology.
usort($items, fn ($a, $b) => $a['_pubDate']->getTimestamp() <=> $b['_pubDate']->getTimestamp());
// Drop the internal sort key — downstream nodes shouldn't see it.
$items = array_map(function (array $item): array {
unset($item['_pubDate']);
return $item;
}, $items);
return [$items, $newestSeen];
}
private function spawnSiblings(AutomationRun $parent, string $fetchNodeId, array $items): void
{
if ($items === []) {
return;
}
$nextNodeId = $this->findNextNodeId($parent, $fetchNodeId);
foreach ($items as $item) {
$sibling = AutomationRun::create([
'automation_id' => $parent->automation_id,
'is_manual' => $parent->is_manual,
'is_dry_run' => $parent->is_dry_run,
'status' => RunStatus::Pending,
'context' => array_merge($parent->context ?? [], ['fetched' => $item]),
]);
if ($nextNodeId === null) {
$sibling->update(['status' => RunStatus::Completed, 'finished_at' => now()]);
continue;
}
ProcessAutomationNode::dispatch($sibling, $nextNodeId);
}
}
private function findNextNodeId(AutomationRun $run, string $fromNodeId): ?string
{
$connection = collect($run->automation->connections ?? [])
->first(fn ($c) => $c['source'] === $fromNodeId && ($c['source_handle'] ?? 'default') === 'default');
return $connection['target'] ?? null;
}
private function parseWatermark(?string $stored): CarbonImmutable
{
if ($stored === null) {
return CarbonImmutable::now();
}
try {
return CarbonImmutable::parse($stored);
} catch (Throwable) {
return CarbonImmutable::now();
}
}
private function parsePubDate(string $raw): ?CarbonImmutable
{
if ($raw === '') {
return null;
}
try {
return CarbonImmutable::parse($raw);
} catch (Throwable) {
return null;
}
}
}

View file

@ -0,0 +1,283 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Node;
use App\Actions\Post\CreatePost;
use App\Ai\Agents\PostContentGenerator;
use App\Ai\Agents\PostContentHumanizer;
use App\DataTransferObjects\Automation\NodeRunResult;
use App\Enums\PostPlatform\ContentType;
use App\Models\AutomationRun;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Ai\RecordAiUsage;
use App\Services\Automation\ExpressionResolver;
use Illuminate\Support\Facades\Log;
class RunGenerateNode
{
public function __construct(
private ExpressionResolver $resolver,
) {}
public function __invoke(AutomationRun $run, array $config): NodeRunResult
{
$context = $run->context ?? [];
$prompt = $this->resolver->resolve(data_get($config, 'prompt_template', ''), $context);
$accountsConfig = $this->resolveAccountsConfig($config);
['format' => $format, 'slide_count' => $slideCount] = $this->deriveFormat($accountsConfig, $config);
$accountIds = array_values(array_filter(array_map(
fn ($a) => data_get($a, 'social_account_id'),
$accountsConfig,
)));
$workspace = $run->automation->workspace;
$activeAccounts = SocialAccount::query()
->whereIn('id', $accountIds)
->where('workspace_id', $workspace->id)
->active()
->get()
->keyBy('id');
if ($accountIds !== [] && $activeAccounts->isEmpty()) {
Log::warning('RunGenerateNode: no active social accounts found, skipping account assignment', [
'automation_id' => $run->automation_id,
'social_account_ids' => $accountIds,
]);
}
$agent = new PostContentGenerator(
workspace: $workspace,
format: $format,
slideCount: $slideCount,
);
$generatorResponse = $agent->prompt($prompt);
RecordAiUsage::recordText(
workspace: $workspace,
promptTokens: $generatorResponse->usage->promptTokens,
completionTokens: $generatorResponse->usage->completionTokens,
provider: (string) config('ai.default'),
model: (string) config('ai.default_text_model'),
metadata: ['agent' => 'post_generator', 'format' => $format, 'source' => 'automation'],
);
$structured = $generatorResponse->structured ?? [];
$structured = $this->humanize($workspace, $structured, $format);
$content = $format === 'carousel'
? (string) data_get($structured, 'caption', '')
: (string) data_get($structured, 'content', '');
$user = $this->resolveUser($run);
$platforms = [];
foreach ($accountsConfig as $entry) {
$accountId = data_get($entry, 'social_account_id');
if (! $accountId || ! $activeAccounts->has($accountId)) {
if ($accountId) {
Log::warning('RunGenerateNode: account no longer active, skipping', [
'automation_id' => $run->automation_id,
'social_account_id' => $accountId,
]);
}
continue;
}
$platforms[] = [
'social_account_id' => $accountId,
'content_type' => data_get($entry, 'content_type'),
'meta' => data_get($entry, 'meta', []),
];
}
// Dry runs do the AI work (so the user sees a real generation) but
// never persist a Post. Downstream nodes (Publish) read `is_dry_run`
// and skip their persistence too.
if ($run->is_dry_run) {
return NodeRunResult::completed(output: [
'generated' => [
'post_id' => null,
'content' => $content,
'dry_run' => true,
],
]);
}
$post = CreatePost::execute($workspace, $user, [
'content' => $content,
'media' => [],
'platforms' => $platforms,
]);
$run->update(['generated_post_id' => $post->id]);
return NodeRunResult::completed(output: [
'generated' => [
'post_id' => $post->id,
'content' => $content,
'post_url' => route('app.posts.show', $post->id),
],
]);
}
/**
* @param array<string, mixed> $structured
* @return array<string, mixed>
*/
private function humanize(Workspace $workspace, array $structured, string $format): array
{
try {
$input = $format === 'carousel'
? [
'caption' => data_get($structured, 'caption', ''),
'slides' => array_map(
fn ($s) => [
'title' => data_get($s, 'title', ''),
'body' => data_get($s, 'body', ''),
],
data_get($structured, 'slides', []),
),
]
: [
'content' => data_get($structured, 'content', ''),
'image_title' => data_get($structured, 'image_title', ''),
'image_body' => data_get($structured, 'image_body', ''),
];
$humanizer = new PostContentHumanizer($workspace, $format);
$response = $humanizer->prompt(json_encode($input, JSON_UNESCAPED_UNICODE));
$humanized = $response->structured ?? [];
RecordAiUsage::recordText(
workspace: $workspace,
promptTokens: $response->usage->promptTokens,
completionTokens: $response->usage->completionTokens,
provider: (string) config('ai.default'),
model: (string) config('ai.default_text_model'),
metadata: ['agent' => 'post_humanizer', 'format' => $format, 'source' => 'automation'],
);
if ($format === 'carousel') {
$structured['caption'] = data_get($humanized, 'caption', $structured['caption'] ?? '');
$originalSlides = $structured['slides'] ?? [];
$humanizedSlides = data_get($humanized, 'slides', []);
foreach ($originalSlides as $i => $slide) {
if (isset($humanizedSlides[$i])) {
$originalSlides[$i]['title'] = data_get($humanizedSlides[$i], 'title', $slide['title'] ?? '');
$originalSlides[$i]['body'] = data_get($humanizedSlides[$i], 'body', $slide['body'] ?? '');
}
}
$structured['slides'] = $originalSlides;
} else {
$structured['content'] = data_get($humanized, 'content', $structured['content'] ?? '');
$structured['image_title'] = data_get($humanized, 'image_title', $structured['image_title'] ?? '');
$structured['image_body'] = data_get($humanized, 'image_body', $structured['image_body'] ?? '');
}
} catch (\Throwable $e) {
Log::warning('RunGenerateNode: PostContentHumanizer failed, using generator output as-is', [
'error' => $e->getMessage(),
]);
}
return $structured;
}
/**
* Derive the generator format and slide count from per-account content types.
*
* Carousel-capable content types:
* - instagram_carousel (Instagram feed carousel)
* - linkedin_carousel (LinkedIn personal carousel PDF)
* - linkedin_page_carousel (LinkedIn page carousel PDF)
* - pinterest_carousel (Pinterest carousel pin)
* - tiktok_photo (TikTok photo carousel)
*
* When at least one account has a carousel-capable content type AND
* target_slide_count > 1, the generator is told to produce a carousel with
* that many slides. Otherwise it falls back to a single-post format.
*
* @param array<int, array{social_account_id: string, content_type: ?string, meta: array<string, mixed>}> $accountsConfig
* @param array<string, mixed> $config
* @return array{format: string, slide_count: int}
*/
public function deriveFormat(array $accountsConfig, array $config): array
{
$carouselCapable = [
ContentType::InstagramCarousel->value,
ContentType::LinkedInCarousel->value,
ContentType::LinkedInPageCarousel->value,
ContentType::PinterestCarousel->value,
ContentType::TikTokPhoto->value,
];
$hasCarouselAccount = false;
foreach ($accountsConfig as $entry) {
if (in_array(data_get($entry, 'content_type'), $carouselCapable, strict: true)) {
$hasCarouselAccount = true;
break;
}
}
$targetSlideCount = (int) data_get($config, 'target_slide_count', 1);
if ($hasCarouselAccount && $targetSlideCount > 1) {
return ['format' => 'carousel', 'slide_count' => $targetSlideCount];
}
return ['format' => 'single', 'slide_count' => 1];
}
private function resolveUser(AutomationRun $run): User
{
if ($run->automation->user_id) {
return $run->automation->user;
}
return $run->automation->workspace->owner;
}
/**
* Read the current `accounts` shape and fall back to the legacy
* `social_account_ids` array so older automations keep running until
* the user re-opens and saves the node.
*
* @param array<string, mixed> $config
* @return array<int, array{social_account_id: string, content_type: ?string, meta: array<string, mixed>}>
*/
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));
}
}

View file

@ -0,0 +1,299 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Node;
use App\DataTransferObjects\Automation\NodeRunResult;
use App\Enums\Automation\Run\Status as RunStatus;
use App\Jobs\Automation\ProcessAutomationNode;
use App\Models\AutomationNodeState;
use App\Models\AutomationRun;
use App\Services\Automation\ExpressionResolver;
use Carbon\CarbonImmutable;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Http;
use Throwable;
/**
* Generalized HTTP node supersedes the old `fetch_json` polling-only node.
*
* Two execution modes share one config:
* - **Single request mode** (`items_path` empty): one HTTP call, the parsed
* response is set on `context.fetched` as a single payload. Useful for
* enrichment ("look up user X before generating") or webhook-style fan-out.
* - **Polling mode** (`items_path` set): same as the old fetch extract an
* items array, filter by watermark (when `item_date_path` is provided),
* process the oldest unseen item in the current run and spawn siblings for
* the remainder. Each item ends up driving its own downstream branch.
*
* Auth types: none, bearer, basic, api_key. Credentials are stored encrypted
* on the Automation model (see `Automation::booted()`) and decrypted here.
*/
class RunHttpRequestNode
{
public function __construct(private ExpressionResolver $resolver) {}
public function __invoke(AutomationRun $run, array $config): NodeRunResult
{
$url = (string) data_get($config, 'url', '');
$method = strtoupper((string) data_get($config, 'method', 'GET'));
$itemsPath = data_get($config, 'items_path');
$itemKeyPath = data_get($config, 'item_key_path');
$itemDatePath = data_get($config, 'item_date_path');
$nodeId = (string) $run->current_node_id;
$context = $run->context ?? [];
if ($url === '') {
return NodeRunResult::failed('HTTP request node missing url.');
}
$resolvedUrl = $this->resolver->resolve($url, $context);
$request = $this->buildRequest($config, $context);
$body = $this->buildJsonBody($method, $config, $context);
try {
$response = match ($method) {
'GET' => $request->get($resolvedUrl),
'DELETE' => $request->delete($resolvedUrl),
'POST' => $request->post($resolvedUrl, $body),
'PUT' => $request->put($resolvedUrl, $body),
'PATCH' => $request->patch($resolvedUrl, $body),
default => null,
};
} catch (Throwable $e) {
return NodeRunResult::failed('HTTP request threw an exception.', ['message' => $e->getMessage()]);
}
if ($response === null) {
return NodeRunResult::failed("Unsupported HTTP method: {$method}");
}
if (! $response->successful()) {
return NodeRunResult::failed('HTTP request failed.', [
'status' => $response->status(),
'body' => substr($response->body(), 0, 500),
]);
}
$body = $response->json();
$useItems = is_string($itemsPath) && $itemsPath !== '';
if (! $useItems) {
// Single-response mode: pass the whole body forward as `fetched`.
return NodeRunResult::completed([
'fetch' => ['count' => 1, 'spawned' => 0],
'fetched' => $body,
]);
}
$rawItems = data_get($body, $itemsPath, []);
if (! is_array($rawItems)) {
return NodeRunResult::failed('Items path did not resolve to an array.');
}
// Dry runs bypass the watermark entirely: they neither load nor advance
// the persisted state, AND they process every item so the user actually
// sees data flow (with a "now" watermark, fresh tests would yield 0
// items on any feed older than today and look broken).
$useWatermark = is_string($itemDatePath) && $itemDatePath !== '' && ! $run->is_dry_run;
$state = $useWatermark ? AutomationNodeState::for($run->automation_id, $nodeId) : null;
$watermark = $useWatermark
? $this->parseWatermark(data_get($state->data, 'last_item_date'))
: null;
$newestSeen = null;
$newItems = [];
foreach ($rawItems as $item) {
if (! is_array($item)) {
continue;
}
if ($useWatermark) {
$itemDate = $this->parseDate(data_get($item, $itemDatePath));
if ($itemDate === null) {
continue;
}
if ($newestSeen === null || $itemDate->greaterThan($newestSeen)) {
$newestSeen = $itemDate;
}
if (! $itemDate->greaterThan($watermark)) {
continue;
}
}
$key = $itemKeyPath ? data_get($item, $itemKeyPath) : null;
$key = ($key === null || $key === '') ? hash('sha256', json_encode($item)) : (string) $key;
$newItems[] = array_merge($item, ['_key' => $key]);
}
if ($useWatermark && $newestSeen !== null && $state !== null) {
$state->update(['data' => array_merge($state->data ?? [], [
'last_item_date' => $newestSeen->toIso8601String(),
])]);
}
if ($newItems === []) {
return NodeRunResult::completed(['fetch' => ['count' => 0]], nextHandle: 'no_items');
}
$first = array_shift($newItems);
// Dry runs skip sibling spawning so the test stays a single in-memory
// walk through one item — see TestAutomation's dry-run contract.
if (! $run->is_dry_run) {
$this->spawnSiblings($run, $nodeId, $newItems);
}
return NodeRunResult::completed([
'fetch' => ['count' => count($newItems) + 1, 'spawned' => $run->is_dry_run ? 0 : count($newItems)],
'fetched' => $first,
]);
}
/**
* @param array<string, mixed> $config
* @param array<string, mixed> $context
*/
private function buildRequest(array $config, array $context): PendingRequest
{
$request = Http::asJson();
$headers = [];
foreach ((array) data_get($config, 'headers', []) as $k => $v) {
$headers[$k] = $this->resolver->resolve((string) $v, $context);
}
$authType = data_get($config, 'auth_type', 'none');
if ($authType === 'bearer') {
$token = $this->decrypt((string) data_get($config, 'auth_token', ''));
if ($token !== '') {
$request = $request->withToken($this->resolver->resolve($token, $context));
}
} elseif ($authType === 'basic') {
$user = (string) data_get($config, 'auth_username', '');
$pass = $this->decrypt((string) data_get($config, 'auth_password', ''));
if ($user !== '' || $pass !== '') {
$request = $request->withBasicAuth(
$this->resolver->resolve($user, $context),
$this->resolver->resolve($pass, $context),
);
}
} elseif ($authType === 'api_key') {
$headerName = (string) data_get($config, 'auth_header_name', 'X-API-Key');
$token = $this->decrypt((string) data_get($config, 'auth_token', ''));
if ($token !== '') {
$headers[$headerName] = $this->resolver->resolve($token, $context);
}
}
if ($headers !== []) {
$request = $request->withHeaders($headers);
}
return $request;
}
/**
* @param array<string, mixed> $config
* @param array<string, mixed> $context
* @return array<string, mixed>
*/
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<int, array<string, mixed>> $items
*/
private function spawnSiblings(AutomationRun $parent, string $fetchNodeId, array $items): void
{
if ($items === []) {
return;
}
$nextNodeId = $this->findNextNodeId($parent, $fetchNodeId);
foreach ($items as $item) {
$sibling = AutomationRun::create([
'automation_id' => $parent->automation_id,
'is_manual' => $parent->is_manual,
'is_dry_run' => $parent->is_dry_run,
'status' => RunStatus::Pending,
'context' => array_merge($parent->context ?? [], ['fetched' => $item]),
]);
if ($nextNodeId === null) {
$sibling->update(['status' => RunStatus::Completed, 'finished_at' => now()]);
continue;
}
ProcessAutomationNode::dispatch($sibling, $nextNodeId);
}
}
private function findNextNodeId(AutomationRun $run, string $fromNodeId): ?string
{
$connection = collect($run->automation->connections ?? [])
->first(fn ($c) => $c['source'] === $fromNodeId && ($c['source_handle'] ?? 'default') === 'default');
return $connection['target'] ?? null;
}
private function decrypt(string $value): string
{
if ($value === '') {
return '';
}
try {
return Crypt::decryptString($value);
} catch (Throwable) {
// Value isn't an encrypted payload (legacy plain text or already decrypted).
return $value;
}
}
private function parseWatermark(?string $stored): CarbonImmutable
{
if ($stored === null) {
return CarbonImmutable::now();
}
try {
return CarbonImmutable::parse($stored);
} catch (Throwable) {
return CarbonImmutable::now();
}
}
private function parseDate(mixed $raw): ?CarbonImmutable
{
if (! is_string($raw) && ! is_numeric($raw)) {
return null;
}
try {
return CarbonImmutable::parse((string) $raw);
} catch (Throwable) {
return null;
}
}
}

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Node;
use App\DataTransferObjects\Automation\NodeRunResult;
use App\Enums\Automation\Publish\Mode;
use App\Enums\Post\Status as PostStatus;
use App\Jobs\PublishPost;
use App\Models\AutomationRun;
use App\Models\Post;
class RunPublishNode
{
public function __invoke(AutomationRun $run, array $config): NodeRunResult
{
$mode = Mode::from($config['mode'] ?? 'now');
// Dry runs never have a generated Post (RunGenerateNode skipped
// persistence). Mirror the call site without touching the DB or
// queueing PublishPost.
if ($run->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),
]);
}
}

View file

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Node;
use App\DataTransferObjects\Automation\NodeRunResult;
use App\Models\AutomationRun;
use App\Services\Automation\ExpressionResolver;
use Illuminate\Support\Facades\Http;
class RunWebhookNode
{
public function __construct(private ExpressionResolver $resolver) {}
public function __invoke(AutomationRun $run, array $config): NodeRunResult
{
$url = $this->resolver->resolve($config['url'] ?? '', $run->context ?? []);
$method = strtoupper($config['method'] ?? 'POST');
$headers = [];
foreach ($config['headers'] ?? [] as $k => $v) {
$headers[$k] = $this->resolver->resolve((string) $v, $run->context ?? []);
}
$payloadJson = $this->resolver->resolve($config['payload_template'] ?? '{}', $run->context ?? []);
$payload = json_decode($payloadJson, true) ?? [];
$response = Http::withHeaders($headers)
->send($method, $url, ['json' => $payload]);
if ($response->serverError()) {
return NodeRunResult::failed(__('automations.errors.webhook_server_error'), [
'status' => $response->status(),
'body' => substr($response->body(), 0, 500),
]);
}
return NodeRunResult::completed(output: [
'webhook' => [
'status' => $response->status(),
'body' => substr($response->body(), 0, 500),
],
]);
}
}

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Run;
use App\Enums\Automation\Run\Status;
use App\Jobs\Automation\ProcessAutomationNode;
use App\Models\AutomationRun;
class AdvanceAutomationRun
{
public function __invoke(AutomationRun $run, string $fromNodeId, string $handle = 'default'): void
{
$automation = $run->automation;
$connection = collect($automation->connections ?? [])
->first(fn ($c) => $c['source'] === $fromNodeId && ($c['source_handle'] ?? 'default') === $handle);
if ($connection === null) {
$run->update([
'status' => Status::Completed,
'finished_at' => now(),
'current_node_id' => null,
]);
return;
}
ProcessAutomationNode::dispatch($run, $connection['target']);
}
}

View file

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Run;
use App\Enums\Automation\Run\Status;
use App\Jobs\Automation\ProcessAutomationNode;
use App\Models\Automation;
use App\Models\AutomationRun;
use App\Models\AutomationTriggerItem;
class DispatchAutomationRun
{
public function __invoke(Automation $automation, AutomationTriggerItem $triggerItem): AutomationRun
{
$firstNodeId = $this->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;
}
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Run;
use App\Enums\Automation\Run\Status;
use App\Jobs\Automation\ProcessAutomationNode;
use App\Models\AutomationRun;
class RetryRunFromNode
{
public function __invoke(AutomationRun $run, string $nodeId): void
{
if ($run->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);
}
}

View file

@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Run;
use App\Enums\Automation\Run\Status;
use App\Enums\Automation\Trigger\Type as TriggerType;
use App\Jobs\Automation\ProcessAutomationNode;
use App\Models\Automation;
use App\Models\AutomationRun;
use App\Models\Post;
/**
* Kicks off a manual run from the editor without waiting for the real trigger
* to fire. Mirrors n8n's "Execute workflow" the same job pipeline runs, but
* the trigger payload is synthesized so the user can see exactly what each
* node does end-to-end. Runs are flagged `is_manual=true` so they're filtered
* from production audit views.
*
* When `$withRealData` is false (the default), the run is marked `is_dry_run`
* so side-effectful nodes (publish, generate, watermark advancement, sibling
* spawning) short-circuit. The run row is auto-deleted after reaching a
* terminal state dry tests intentionally leave no trace.
*/
class TestAutomation
{
public function __invoke(Automation $automation, bool $withRealData = false): AutomationRun
{
$triggerNode = collect($automation->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<string, mixed> $triggerNode
* @return array<string, mixed>
*/
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<string, mixed>
*/
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<string, mixed>|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;
}
}

View file

@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Trigger;
use App\Enums\Automation\Run\Status as RunStatus;
use App\Enums\Automation\Status as AutomationStatus;
use App\Enums\Automation\Trigger\Type as TriggerType;
use App\Jobs\Automation\ProcessAutomationNode;
use App\Models\Automation;
use App\Models\AutomationRun;
use App\Models\Post;
/**
* Walks the workspace's active automations and dispatches a run for each one
* whose Trigger node matches the given post-related event (PostPublished /
* PostScheduled). The post payload is placed at `context.trigger.post` so
* downstream nodes can reference it via templates like `{{ trigger.post.id }}`.
*
* V1 limitation: every post fires triggers, regardless of whether the post
* itself was created by an automation. If a future use case introduces loops
* (automation X publishes trigger fires X publishes again), we'll need a
* `posts.created_by_automation_run_id` column to skip them.
*/
class DispatchPostTriggerAutomations
{
public function __invoke(Post $post, TriggerType $triggerType): void
{
$automations = Automation::query()
->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);
}
}

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Trigger;
use App\Actions\Automation\TriggerItem\EnrollTriggerItem;
use App\Models\Automation;
use Cron\CronExpression;
class FireScheduleTrigger
{
public function __construct(private EnrollTriggerItem $enroll) {}
public function __invoke(Automation $automation): bool
{
$triggerNode = collect($automation->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;
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\TriggerItem;
use App\Actions\Automation\Run\DispatchAutomationRun;
use App\Models\Automation;
use App\Models\AutomationRun;
use App\Models\AutomationTriggerItem;
class EnrollTriggerItem
{
public function __construct(private DispatchAutomationRun $dispatchRun) {}
public function __invoke(Automation $automation, string $itemKey, array $payload): ?AutomationRun
{
$existing = AutomationTriggerItem::where('automation_id', $automation->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);
}
}

View file

@ -31,7 +31,7 @@ class CreatePost
* media?: array<int, mixed>,
* date?: ?string,
* scheduled_at?: ?string,
* platforms?: array<int, array{social_account_id: string, content_type?: string}>,
* platforms?: array<int, array{social_account_id: string, content_type?: string, meta?: array<string, mixed>}>,
* label_ids?: array<int, string>
* } $data
*/
@ -62,6 +62,17 @@ public static function execute(Workspace $workspace, User $user, array $data): P
$updates['content_type'] = $contentType;
}
$meta = data_get($platformData, 'meta');
if (is_array($meta) && $meta !== []) {
$existing = $post->postPlatforms()
->where('social_account_id', $accountId)
->first();
if ($existing) {
$updates['meta'] = array_merge($existing->meta ?? [], $meta);
}
}
$post->postPlatforms()
->where('social_account_id', $accountId)
->update($updates);

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Broadcasting;
use App\Models\Automation;
use App\Models\User;
class AutomationChannel
{
public function join(User $user, Automation $automation): bool
{
return $automation->workspace->hasMember($user);
}
}

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Automation;
use App\Actions\Automation\Trigger\FireScheduleTrigger;
use App\Enums\Automation\Status;
use App\Models\Automation;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
#[Signature('automation:fire-schedule')]
#[Description('Fire scheduled automations whose cron matches now')]
class FireScheduleTriggers extends Command
{
public function handle(FireScheduleTrigger $fire): int
{
Automation::query()
->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;
}
}

View file

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Automation;
use App\Actions\Automation\Run\AdvanceAutomationRun;
use App\Enums\Automation\Run\Status;
use App\Models\AutomationRun;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
#[Signature('automation:process-delays')]
#[Description('Wake up runs that finished their delay window')]
class ProcessAutomationDelays extends Command
{
public function handle(AdvanceAutomationRun $advance): int
{
AutomationRun::query()
->where('status', Status::Waiting)
->where('next_action_at', '<=', now())
->lockForUpdate()
->chunkById(50, function ($runs) use ($advance) {
DB::transaction(function () use ($runs, $advance) {
foreach ($runs as $run) {
$run->update([
'status' => Status::Running,
'next_action_at' => null,
]);
$advance($run, $run->current_node_id);
}
});
});
return self::SUCCESS;
}
}

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\DataTransferObjects\Automation;
use App\Enums\Automation\NodeRun\Status;
use DateTimeInterface;
class NodeRunResult
{
public function __construct(
public readonly Status $status,
public readonly array $output = [],
public readonly string $nextHandle = 'default',
public readonly ?DateTimeInterface $sleepUntil = null,
public readonly ?array $error = null,
) {}
public static function completed(array $output = [], string $nextHandle = 'default'): self
{
return new self(Status::Completed, $output, $nextHandle);
}
public static function sleep(DateTimeInterface $until): self
{
return new self(Status::Completed, sleepUntil: $until);
}
public static function failed(string $message, ?array $extra = null): self
{
return new self(Status::Failed, error: array_merge(['message' => $message], $extra ?? []));
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Enums\Automation\Condition;
enum Operator: string
{
case Contains = 'contains';
case NotContains = 'not_contains';
case Equals = 'equals';
case NotEquals = 'not_equals';
case Matches = 'matches';
case GreaterThan = 'greater_than';
case LessThan = 'less_than';
}

View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Enums\Automation\Node;
enum Type: string
{
case Trigger = 'trigger';
case Generate = 'generate';
case Delay = 'delay';
case Condition = 'condition';
case Publish = 'publish';
case Webhook = 'webhook';
case End = 'end';
case FetchRss = 'fetch_rss';
case HttpRequest = 'http_request';
}

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Enums\Automation\NodeRun;
enum Status: string
{
case Running = 'running';
case Completed = 'completed';
case Failed = 'failed';
case Skipped = 'skipped';
}

View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Enums\Automation\Publish;
enum Mode: string
{
case Now = 'now';
case Scheduled = 'scheduled';
case Draft = 'draft';
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Enums\Automation\Run;
enum Status: string
{
case Pending = 'pending';
case Running = 'running';
case Waiting = 'waiting';
case Completed = 'completed';
case Failed = 'failed';
case Cancelled = 'cancelled';
}

View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Enums\Automation;
enum Status: string
{
case Draft = 'draft';
case Active = 'active';
case Paused = 'paused';
}

View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Enums\Automation\Trigger;
enum Type: string
{
case Schedule = 'schedule';
case PostPublished = 'post_published';
case PostScheduled = 'post_scheduled';
}

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\AutomationRun;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Lightweight signal that a run (or one of its node runs) advanced. Carries
* only identifiers the client refetches the full state via the show-run
* endpoint to keep resource serialization centralized.
*/
class AutomationRunUpdated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(public AutomationRun $run) {}
public function broadcastAs(): string
{
return 'automation.run.updated';
}
public function broadcastOn(): array
{
return [
new PrivateChannel("automation.{$this->run->automation_id}"),
];
}
/**
* @return array<string, string>
*/
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';
}
}

View file

@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\App;
use App\Actions\Automation\Automation\ActivateAutomation;
use App\Actions\Automation\Automation\CreateAutomation;
use App\Actions\Automation\Automation\PauseAutomation;
use App\Actions\Automation\Automation\UpdateAutomation;
use App\Actions\Automation\Run\RetryRunFromNode;
use App\Actions\Automation\Run\TestAutomation;
use App\Enums\SocialAccount\Platform;
use App\Http\Controllers\Controller;
use App\Http\Requests\App\Automations\ActivateAutomationRequest;
use App\Http\Requests\App\Automations\PauseAutomationRequest;
use App\Http\Requests\App\Automations\RetryRunRequest;
use App\Http\Requests\App\Automations\StoreAutomationRequest;
use App\Http\Requests\App\Automations\TestAutomationRequest;
use App\Http\Requests\App\Automations\UpdateAutomationRequest;
use App\Http\Resources\App\PlatformConfigResource;
use App\Http\Resources\App\SocialAccountResource;
use App\Http\Resources\AutomationNodeRunResource;
use App\Http\Resources\AutomationResource;
use App\Http\Resources\AutomationRunResource;
use App\Http\Resources\AutomationTriggerItemResource;
use App\Models\Automation;
use App\Models\AutomationRun;
use App\Services\Social\PinterestPublisher;
use App\Services\Social\TikTokCreatorInfo;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class AutomationController extends Controller
{
public function index(): Response
{
$automations = Inertia::scroll(fn () => AutomationResource::collection(
Automation::query()
->where('workspace_id', request()->user()->current_workspace_id)
->orderByDesc('created_at')
->paginate(config('app.pagination.default'))
));
return Inertia::render('automations/Index', [
'automations' => $automations,
]);
}
public function store(StoreAutomationRequest $request, CreateAutomation $create): RedirectResponse
{
$name = $request->validated('name');
if (! $name || $name === 'automations.default_name') {
$name = __('automations.default_name');
}
$automation = $create(
$request->user()->currentWorkspace,
$request->user(),
$name,
);
return redirect()->route('app.automations.edit', $automation->id);
}
public function edit(Automation $automation): Response
{
$this->authorize('update', $automation);
$socialAccounts = $automation->workspace->socialAccounts()->active()->get();
$platformConfigs = $socialAccounts->mapWithKeys(fn ($account) => [
$account->id => new PlatformConfigResource($account),
]);
$pinterestBoards = $socialAccounts
->where('platform', Platform::Pinterest)
->mapWithKeys(fn ($account) => [
$account->id => rescue(
fn () => app(PinterestPublisher::class)->getBoards($account),
[],
report: false,
),
]);
$tiktokCreatorInfos = $socialAccounts
->where('platform', Platform::TikTok)
->mapWithKeys(fn ($account) => [
$account->id => rescue(
fn () => app(TikTokCreatorInfo::class)->fetch($account),
null,
report: false,
),
])
->filter();
return Inertia::render('automations/Form', [
'automation' => AutomationResource::make($automation),
'socialAccounts' => SocialAccountResource::collection($socialAccounts),
'platformConfigs' => $platformConfigs,
'pinterestBoards' => $pinterestBoards,
'tiktokCreatorInfos' => $tiktokCreatorInfos,
]);
}
public function show(Automation $automation): Response
{
$this->authorize('view', $automation);
return Inertia::render('automations/Show', [
'automation' => AutomationResource::make($automation),
'runs' => AutomationRunResource::collection($automation->runs()->excludingDryRuns()->latest()->take(50)->get()),
'triggerItems' => AutomationTriggerItemResource::collection(
$automation->triggerItems()->with('run')->latest()->take(50)->get()
),
]);
}
public function update(UpdateAutomationRequest $request, Automation $automation, UpdateAutomation $update): RedirectResponse
{
$this->authorize('update', $automation);
$update($automation, $request->validated());
return back();
}
public function destroy(Automation $automation): RedirectResponse
{
$this->authorize('delete', $automation);
$automation->delete();
session()->flash('flash.banner', __('automations.flash.deleted'));
session()->flash('flash.bannerStyle', 'success');
return redirect()->route('app.automations.index');
}
public function activate(ActivateAutomationRequest $request, Automation $automation, ActivateAutomation $activate): RedirectResponse
{
$this->authorize('activate', $automation);
$activate($automation);
return back();
}
public function pause(PauseAutomationRequest $request, Automation $automation, PauseAutomation $pause): RedirectResponse
{
$this->authorize('pause', $automation);
$pause($automation);
return back();
}
public function retryRun(
RetryRunRequest $request,
RetryRunFromNode $retry,
Automation $automation,
AutomationRun $run,
): \Illuminate\Http\Response {
$this->authorize('update', $automation);
abort_unless($run->automation_id === $automation->id, 404);
$nodeId = $request->validated('node_id') ?? $run->current_node_id;
$retry($run, $nodeId);
return response()->noContent();
}
public function test(TestAutomationRequest $request, Automation $automation, TestAutomation $test): JsonResponse
{
$this->authorize('update', $automation);
$run = $test($automation, (bool) $request->validated('with_real_data', false));
return response()->json(['run_id' => $run->id]);
}
public function showRun(Automation $automation, AutomationRun $run): JsonResponse
{
$this->authorize('view', $automation);
abort_unless($run->automation_id === $automation->id, 404);
$run->load('nodeRuns');
return response()->json([
'run' => AutomationRunResource::make($run)->resolve(),
'node_runs' => AutomationNodeRunResource::collection($run->nodeRuns)->resolve(),
]);
}
}

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\App\Automations;
use Illuminate\Foundation\Http\FormRequest;
class ActivateAutomationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [];
}
}

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\App\Automations;
use Illuminate\Foundation\Http\FormRequest;
class PauseAutomationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [];
}
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\App\Automations;
use Illuminate\Foundation\Http\FormRequest;
class RetryRunRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'node_id' => ['nullable', 'string', 'max:255'],
];
}
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\App\Automations;
use Illuminate\Foundation\Http\FormRequest;
class StoreAutomationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:120'],
];
}
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\App\Automations;
use Illuminate\Foundation\Http\FormRequest;
class TestAutomationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'with_real_data' => ['nullable', 'boolean'],
];
}
}

View file

@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\App\Automations;
use App\Enums\Automation\Condition\Operator as ConditionOperator;
use App\Enums\Automation\Node\Type as NodeType;
use App\Enums\Automation\Publish\Mode as PublishMode;
use App\Enums\Automation\Trigger\Type as TriggerType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateAutomationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$rules = [
'name' => ['sometimes', 'string', 'max:120'],
'nodes' => ['sometimes', 'array'],
'nodes.*.id' => ['required', 'string'],
'nodes.*.type' => ['required', 'string', Rule::in(array_column(NodeType::cases(), 'value'))],
'nodes.*.position' => ['required', 'array'],
'nodes.*.position.x' => ['required', 'numeric'],
'nodes.*.position.y' => ['required', 'numeric'],
'nodes.*.data' => ['required', 'array'],
'connections' => ['sometimes', 'array'],
'connections.*.id' => ['required', 'string'],
'connections.*.source' => ['required', 'string'],
'connections.*.target' => ['required', 'string'],
'connections.*.source_handle' => ['nullable', 'string'],
'connections.*.target_handle' => ['nullable', 'string'],
];
// Per-node data validation. We build these dynamically so each node's
// type drives the shape of its `data` payload, and so errors come back
// with full paths like `nodes.2.data.feed_url` for the frontend to map.
$nodes = $this->input('nodes', []);
if (is_array($nodes)) {
foreach ($nodes as $i => $node) {
$type = data_get($node, 'type');
foreach ($this->dataRulesForNodeType($type) as $field => $fieldRules) {
$rules["nodes.{$i}.data.{$field}"] = $fieldRules;
}
}
}
return $rules;
}
/**
* @return array<string, mixed>
*/
public function attributes(): array
{
return [
'nodes.*.data.feed_url' => 'Feed URL',
'nodes.*.data.url' => 'URL',
'nodes.*.data.cron' => 'cron expression',
'nodes.*.data.duration' => 'duration',
'nodes.*.data.unit' => 'unit',
'nodes.*.data.field' => 'field',
'nodes.*.data.operator' => 'operator',
'nodes.*.data.mode' => 'mode',
'nodes.*.data.method' => 'method',
'nodes.*.data.trigger_type' => 'trigger type',
'nodes.*.data.prompt_template' => 'prompt template',
'nodes.*.data.image_source' => 'image source',
'nodes.*.data.accounts' => 'accounts',
];
}
/**
* @return array<string, array<int, mixed>>
*/
private function dataRulesForNodeType(?string $type): array
{
return match ($type) {
NodeType::Trigger->value => [
'trigger_type' => ['required', Rule::in(array_column(TriggerType::cases(), 'value'))],
'cron' => ['required_if:nodes.*.data.trigger_type,'.TriggerType::Schedule->value, 'string'],
],
NodeType::FetchRss->value => [
'feed_url' => ['required', 'url'],
],
NodeType::HttpRequest->value => [
'url' => ['required', 'url'],
'method' => ['required', Rule::in(['GET', 'POST', 'PUT', 'PATCH', 'DELETE'])],
'auth_type' => ['required', Rule::in(['none', 'bearer', 'basic', 'api_key'])],
'auth_token' => ['nullable', 'string'],
'auth_username' => ['nullable', 'string'],
'auth_password' => ['nullable', 'string'],
'auth_header_name' => ['nullable', 'string'],
'body_template' => ['nullable', 'string'],
'headers' => ['nullable', 'array'],
'headers.*' => ['string'],
'items_path' => ['nullable', 'string'],
'item_key_path' => ['nullable', 'string'],
'item_date_path' => ['nullable', 'string'],
],
NodeType::Generate->value => [
'accounts' => ['required', 'array', 'min:1'],
'prompt_template' => ['required', 'string'],
'image_source' => ['required', Rule::in(['ai', 'unsplash', 'none'])],
'target_slide_count' => ['nullable', 'integer', 'min:1', 'max:20'],
],
NodeType::Delay->value => [
'duration' => ['required', 'integer', 'min:1'],
'unit' => ['required', Rule::in(['minutes', 'hours', 'days'])],
],
NodeType::Condition->value => [
'field' => ['required', 'string'],
'operator' => ['required', Rule::in(array_column(ConditionOperator::cases(), 'value'))],
'value' => ['nullable', 'string'],
],
NodeType::Publish->value => [
'mode' => ['required', Rule::in(array_column(PublishMode::cases(), 'value'))],
'scheduled_offset' => ['nullable', 'integer', 'min:0'],
],
NodeType::Webhook->value => [
'url' => ['required', 'url'],
'method' => ['required', Rule::in(['GET', 'POST', 'PUT', 'PATCH', 'DELETE'])],
'payload_template' => ['nullable', 'string'],
'headers' => ['nullable', 'array'],
'headers.*' => ['string'],
],
NodeType::End->value => [
'reason' => ['nullable', 'string'],
],
default => [],
};
}
}

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class AutomationNodeRunResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
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,
];
}
}

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Automation;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class AutomationResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'workspace_id' => $this->workspace_id,
'name' => $this->name,
'status' => $this->status->value,
'nodes' => $this->maskSensitiveNodeFields($this->nodes ?? []),
'connections' => $this->connections ?? [],
'activated_at' => $this->activated_at,
'paused_at' => $this->paused_at,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
/**
* Replace any stored credentials with a placeholder before they leave
* the server. The frontend treats the placeholder as "keep current" on
* save (see Automation::booted()), so editing other fields doesn't wipe
* the stored secret.
*
* @param array<int, array<string, mixed>> $nodes
* @return array<int, array<string, mixed>>
*/
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;
}
}

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class AutomationRunResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
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,
];
}
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class AutomationTriggerItemResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
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')),
];
}
}

View file

@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Automation;
use App\Actions\Automation\Node\RunConditionNode;
use App\Actions\Automation\Node\RunDelayNode;
use App\Actions\Automation\Node\RunEndNode;
use App\Actions\Automation\Node\RunFetchRssNode;
use App\Actions\Automation\Node\RunGenerateNode;
use App\Actions\Automation\Node\RunHttpRequestNode;
use App\Actions\Automation\Node\RunPublishNode;
use App\Actions\Automation\Node\RunWebhookNode;
use App\Actions\Automation\Run\AdvanceAutomationRun;
use App\DataTransferObjects\Automation\NodeRunResult;
use App\Enums\Automation\Node\Type as NodeType;
use App\Enums\Automation\NodeRun\Status as NodeRunStatus;
use App\Enums\Automation\Run\Status as RunStatus;
use App\Models\AutomationNodeRun;
use App\Models\AutomationRun;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use LogicException;
use Throwable;
class ProcessAutomationNode implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1;
public function __construct(
public AutomationRun $run,
public string $nodeId,
) {
$this->onQueue('automations');
}
public function handle(AdvanceAutomationRun $advance): void
{
$this->run->refresh();
if (! in_array($this->run->status, [RunStatus::Pending, RunStatus::Running, RunStatus::Waiting], true)) {
return;
}
$node = collect($this->run->automation->nodes ?? [])->firstWhere('id', $this->nodeId);
if ($node === null) {
$this->run->update([
'status' => RunStatus::Failed,
'error' => ['message' => __('automations.errors.node_no_longer_exists', ['node_id' => $this->nodeId])],
'finished_at' => now(),
]);
return;
}
$nodeType = NodeType::from($node['type']);
$this->run->update([
'status' => RunStatus::Running,
'current_node_id' => $this->nodeId,
'started_at' => $this->run->started_at ?? now(),
]);
$nodeRun = AutomationNodeRun::create([
'run_id' => $this->run->id,
'node_id' => $this->nodeId,
'node_type' => $nodeType,
'status' => NodeRunStatus::Running,
'input' => $this->run->context,
'started_at' => now(),
]);
try {
$result = $this->executeNode($nodeType, $node['data'] ?? []);
} catch (Throwable $e) {
$result = NodeRunResult::failed($e->getMessage(), ['class' => $e::class]);
}
$nodeRun->update([
'status' => $result->status,
'output' => $result->output,
'error' => $result->error,
'finished_at' => now(),
]);
if ($result->status === NodeRunStatus::Failed) {
$this->run->update([
'status' => RunStatus::Failed,
'error' => array_merge(['node_id' => $this->nodeId], $result->error ?? []),
'finished_at' => now(),
]);
return;
}
$this->run->update([
'context' => array_merge($this->run->context ?? [], $result->output),
]);
if ($result->sleepUntil !== null) {
$this->run->update([
'status' => RunStatus::Waiting,
'next_action_at' => $result->sleepUntil,
]);
return;
}
$advance($this->run, $this->nodeId, $result->nextHandle);
}
private function executeNode(NodeType $type, array $config): NodeRunResult
{
$handler = match ($type) {
NodeType::Generate => app(RunGenerateNode::class),
NodeType::Delay => app(RunDelayNode::class),
NodeType::Condition => app(RunConditionNode::class),
NodeType::Publish => app(RunPublishNode::class),
NodeType::Webhook => app(RunWebhookNode::class),
NodeType::End => app(RunEndNode::class),
NodeType::FetchRss => app(RunFetchRssNode::class),
NodeType::HttpRequest => app(RunHttpRequestNode::class),
NodeType::Trigger => throw new LogicException('Trigger nodes are not executed as run steps.'),
};
return $handler($this->run, $config);
}
}

125
app/Models/Automation.php Normal file
View file

@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Enums\Automation\Status;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Crypt;
use Throwable;
class Automation extends Model
{
use HasFactory;
use HasUuids;
/**
* Fields inside `nodes[].data` that hold sensitive credentials. Stored
* encrypted on save and never returned to the frontend in plain text
* AutomationResource masks them with PLACEHOLDER on output.
*/
public const SENSITIVE_NODE_FIELDS = ['auth_token', 'auth_password'];
public const SENSITIVE_PLACEHOLDER = '••••••••';
protected $guarded = [];
protected $casts = [
'status' => Status::class,
'nodes' => 'array',
'connections' => 'array',
'activated_at' => 'datetime',
'paused_at' => 'datetime',
];
protected static function booted(): void
{
static::saving(function (self $automation): void {
$automation->nodes = self::encryptSensitiveFields(
$automation->nodes ?? [],
$automation->getOriginal('nodes') ?? [],
);
});
}
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function triggerItems(): HasMany
{
return $this->hasMany(AutomationTriggerItem::class);
}
public function runs(): HasMany
{
return $this->hasMany(AutomationRun::class);
}
/**
* Walks both the incoming and stored node lists and reconciles sensitive
* fields: a PLACEHOLDER value means "user didn't change it" (frontend
* never received the real value) so we keep the existing ciphertext. Plain
* text values get encrypted; already-encrypted strings pass through.
*
* @param array<int, array<string, mixed>> $incoming
* @param array<int, array<string, mixed>>|string $original
* @return array<int, array<string, mixed>>
*/
private static function encryptSensitiveFields(array $incoming, array|string $original): array
{
$original = is_array($original) ? $original : (json_decode($original, true) ?: []);
$originalById = collect($original)->keyBy('id');
foreach ($incoming as &$node) {
$originalNode = $originalById->get($node['id'] ?? null);
foreach (self::SENSITIVE_NODE_FIELDS as $field) {
$value = data_get($node, "data.{$field}");
if (! is_string($value) || $value === '') {
continue;
}
if ($value === self::SENSITIVE_PLACEHOLDER) {
data_set($node, "data.{$field}", data_get($originalNode, "data.{$field}", ''));
continue;
}
if (self::looksEncrypted($value)) {
continue;
}
data_set($node, "data.{$field}", Crypt::encryptString($value));
}
}
return $incoming;
}
/**
* Quick check for Laravel's `Crypt::encryptString` output without paying
* the cost of a full decrypt attempt. Laravel wraps payloads as base64
* JSON beginning with the canonical `eyJpdiI` ("{"iv":"...) prefix.
*/
private static function looksEncrypted(string $value): bool
{
if (! str_starts_with($value, 'eyJ')) {
return false;
}
try {
Crypt::decryptString($value);
return true;
} catch (Throwable) {
return false;
}
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace App\Models;
use App\Enums\Automation\Node\Type as NodeType;
use App\Enums\Automation\NodeRun\Status;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AutomationNodeRun extends Model
{
use HasFactory;
use HasUuids;
protected $guarded = [];
protected $casts = [
'status' => 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');
}
}

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AutomationNodeState extends Model
{
use HasUuids;
protected $guarded = [];
protected $casts = [
'data' => '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' => []],
);
}
}

View file

@ -0,0 +1,59 @@
<?php
namespace App\Models;
use App\Enums\Automation\Run\Status;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class AutomationRun extends Model
{
use HasFactory;
use HasUuids;
protected $guarded = [];
protected $casts = [
'status' => Status::class,
'context' => 'array',
'error' => 'array',
'is_manual' => 'boolean',
'is_dry_run' => 'boolean',
'next_action_at' => 'datetime',
'started_at' => 'datetime',
'finished_at' => 'datetime',
];
public function automation(): BelongsTo
{
return $this->belongsTo(Automation::class);
}
public function triggerItem(): BelongsTo
{
return $this->belongsTo(AutomationTriggerItem::class, 'trigger_item_id');
}
public function generatedPost(): BelongsTo
{
return $this->belongsTo(Post::class, 'generated_post_id');
}
public function nodeRuns(): HasMany
{
return $this->hasMany(AutomationNodeRun::class, 'run_id');
}
/**
* Hides dry-run rows from user-facing history queries. Internal/analytics
* queries can ignore the scope to see every row.
*/
public function scopeExcludingDryRuns(Builder $query): Builder
{
return $query->where('is_dry_run', false);
}
}

View file

@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
class AutomationTriggerItem extends Model
{
use HasFactory;
use HasUuids;
protected $guarded = [];
protected $casts = [
'payload' => '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');
}
}

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Observers;
use App\Events\AutomationRunUpdated;
use App\Models\AutomationNodeRun;
class AutomationNodeRunObserver
{
public function created(AutomationNodeRun $nodeRun): void
{
AutomationRunUpdated::dispatch($nodeRun->run);
}
public function updated(AutomationNodeRun $nodeRun): void
{
if ($nodeRun->wasChanged(['status', 'output', 'error', 'finished_at'])) {
AutomationRunUpdated::dispatch($nodeRun->run);
}
}
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Observers;
use App\Events\AutomationRunUpdated;
use App\Models\AutomationRun;
class AutomationRunObserver
{
public function created(AutomationRun $run): void
{
AutomationRunUpdated::dispatch($run);
}
public function updated(AutomationRun $run): void
{
// Only broadcast when the user-visible state changes — skip noisy
// updates like `context` mutations from intermediate node merges.
if ($run->wasChanged(['status', 'current_node_id', 'finished_at', 'next_action_at', 'error'])) {
AutomationRunUpdated::dispatch($run);
}
}
}

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Observers;
use App\Actions\Automation\Trigger\DispatchPostTriggerAutomations;
use App\Enums\Automation\Trigger\Type as TriggerType;
use App\Enums\Post\Status as PostStatus;
use App\Models\Post;
class PostObserver
{
public function __construct(private DispatchPostTriggerAutomations $dispatch) {}
public function saved(Post $post): void
{
if (! $post->wasChanged('status')) {
return;
}
$status = $post->status;
if ($status === PostStatus::Published) {
($this->dispatch)($post, TriggerType::PostPublished);
return;
}
if ($status === PostStatus::Scheduled) {
($this->dispatch)($post, TriggerType::PostScheduled);
}
}
}

View file

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\Automation;
use App\Models\User;
class AutomationPolicy
{
public function viewAny(User $user): bool
{
return $user->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);
}
}

View file

@ -8,6 +8,8 @@
use App\Models\AccessToken;
use App\Models\Account;
use App\Models\AiUsageLog;
use App\Models\AutomationNodeRun;
use App\Models\AutomationRun;
use App\Models\Invite;
use App\Models\Media;
use App\Models\Notification;
@ -24,6 +26,9 @@
use App\Models\WorkspaceInvite;
use App\Models\WorkspaceLabel;
use App\Models\WorkspaceSignature;
use App\Observers\AutomationNodeRunObserver;
use App\Observers\AutomationRunObserver;
use App\Observers\PostObserver;
use App\Services\PostHogService;
use App\Services\PostTemplate\Registry as PostTemplateRegistry;
use App\Socialite\InstagramProvider;
@ -84,6 +89,7 @@ public function boot(): void
$this->configureRateLimiting();
$this->configureSocialite();
$this->configureStripeWebhooks();
$this->configureObservers();
Cashier::useCustomerModel(Account::class);
Cashier::useSubscriptionModel(Subscription::class);
@ -96,6 +102,13 @@ public function boot(): void
$this->configurePassport();
}
protected function configureObservers(): void
{
Post::observe(PostObserver::class);
AutomationRun::observe(AutomationRunObserver::class);
AutomationNodeRun::observe(AutomationNodeRunObserver::class);
}
protected function configurePassport(): void
{
Passport::useTokenModel(AccessToken::class);

View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Services\Automation;
use Illuminate\Support\Carbon;
class ExpressionResolver
{
public function resolve(string $template, array $context): string
{
return preg_replace_callback(
'/\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}/',
fn ($matches) => $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);
}
}

View file

@ -54,4 +54,10 @@
], 429)->withHeaders($e->getHeaders());
}
});
$exceptions->render(function (DomainException $e, Request $request) {
if ($request->expectsJson()) {
return response()->json(['message' => $e->getMessage()], 422);
}
});
})->create();

View file

@ -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,
],
],
],
];

View file

@ -0,0 +1,60 @@
<?php
namespace Database\Factories;
use App\Enums\Automation\Status;
use App\Models\Automation;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\Factory;
class AutomationFactory extends Factory
{
protected $model = Automation::class;
public function definition(): array
{
return [
'workspace_id' => 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' => [],
]);
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace Database\Factories;
use App\Enums\Automation\Node\Type as NodeType;
use App\Enums\Automation\NodeRun\Status;
use App\Models\AutomationNodeRun;
use App\Models\AutomationRun;
use Illuminate\Database\Eloquent\Factories\Factory;
class AutomationNodeRunFactory extends Factory
{
protected $model = AutomationNodeRun::class;
public function definition(): array
{
return [
'run_id' => AutomationRun::factory(),
'node_id' => 'node_'.fake()->randomNumber(6),
'node_type' => NodeType::Generate,
'status' => Status::Running,
'input' => [],
'started_at' => now(),
];
}
}

View file

@ -0,0 +1,49 @@
<?php
namespace Database\Factories;
use App\Enums\Automation\Run\Status;
use App\Models\Automation;
use App\Models\AutomationRun;
use Illuminate\Database\Eloquent\Factories\Factory;
class AutomationRunFactory extends Factory
{
protected $model = AutomationRun::class;
public function definition(): array
{
return [
'automation_id' => 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(),
]);
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace Database\Factories;
use App\Models\Automation;
use App\Models\AutomationTriggerItem;
use Illuminate\Database\Eloquent\Factories\Factory;
class AutomationTriggerItemFactory extends Factory
{
protected $model = AutomationTriggerItem::class;
public function definition(): array
{
return [
'automation_id' => Automation::factory(),
'item_key' => fake()->uuid(),
'payload' => [
'title' => fake()->sentence(),
'url' => fake()->url(),
],
'first_seen_at' => now(),
];
}
}

View file

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('automations', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('automation_trigger_items', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('automation_runs', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('automation_node_runs', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('automation_node_states', function (Blueprint $table) {
$table->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');
}
};

248
lang/en/automations.php Normal file
View file

@ -0,0 +1,248 @@
<?php
return [
'title' => 'Automations',
'default_name' => 'New automation',
'actions' => [
'new' => 'New automation',
'edit' => 'Edit',
'save' => 'Save',
'activate' => 'Activate',
'pause' => 'Pause',
'delete' => 'Delete',
'retry' => 'Retry',
'add_node' => 'Add node',
'test' => 'Test',
],
'test' => [
'title' => 'Test run',
'description' => 'Runs the automation end-to-end using a synthesized trigger payload. Useful for validating each node without waiting for the real schedule or feed.',
'starting' => 'Starting test run…',
'in_progress' => 'In progress',
'completed' => 'Completed',
'failed' => 'Failed',
'waiting' => 'Waiting',
'close' => 'Close',
'no_node_runs' => 'Waiting for the first node to start…',
'node_input' => 'Input',
'node_output' => 'Output',
'node_error' => 'Error',
'error_starting' => 'Could not start the test run.',
'with_real_data' => 'With real data',
'real_data_hint' => 'This test will publish posts, advance polling watermarks, and trigger external side effects.',
'dry_badge' => 'Dry run',
],
'status' => [
'draft' => 'Draft',
'active' => 'Active',
'paused' => 'Paused',
],
'index' => [
'empty_title' => 'No automations yet',
'empty_description' => 'Create your first automation to start publishing on autopilot.',
'columns' => [
'name' => 'Name',
'status' => 'Status',
'created' => 'Created',
'actions' => 'Actions',
],
],
'show' => [
'activated' => 'Activated',
'tabs' => [
'overview' => 'Overview',
'runs' => 'Runs',
'trigger_items' => 'Trigger items',
],
'canvas_placeholder' => 'Canvas preview (read-only)',
'empty_runs' => 'No runs yet.',
'empty_trigger_items' => 'No trigger items yet.',
'started' => 'Started',
'run_label' => 'Run',
],
'form' => [
'activate_error_fallback' => 'Could not activate automation.',
'pause_error_fallback' => 'Could not pause automation.',
'save_error_fallback' => 'Could not save automation.',
'save_success' => 'Automation saved.',
'config_title' => ':type config',
'empty_canvas_title' => 'Start building your automation',
'empty_canvas_description' => 'Drag a node from the left panel to get started.',
'name_placeholder' => 'Untitled automation',
],
'nodes' => [
'trigger' => 'Trigger',
'generate' => 'Generate',
'delay' => 'Delay',
'condition' => 'Condition',
'publish' => 'Publish',
'webhook' => 'Webhook',
'end' => 'End',
'end_summary' => 'Stops the automation here',
'fetch_rss' => 'Fetch RSS',
'http_request' => 'HTTP Request',
],
'config' => [
'select_placeholder' => 'Select…',
'trigger' => [
'type' => 'Trigger type',
'types' => [
'schedule' => 'Schedule',
'post_published' => 'When a post is published',
'post_scheduled' => 'When a post is scheduled',
],
'post_published_hint' => 'Runs whenever any post in this workspace is published. The published post becomes available at {{ trigger.post }} for downstream nodes.',
'post_scheduled_hint' => 'Runs whenever any post in this workspace is scheduled. The scheduled post is available at {{ trigger.post }}.',
'schedule' => [
'field' => 'Trigger interval',
'fields' => [
'minutes' => 'Minutes',
'hours' => 'Hours',
'days' => 'Days',
'weeks' => 'Weeks',
'months' => 'Months',
'custom' => 'Custom (Cron)',
],
'minutes_interval' => 'Minutes between triggers',
'hours_interval' => 'Hours between triggers',
'days_interval' => 'Days between triggers',
'hour' => 'Trigger at hour',
'minute' => 'Trigger at minute',
'weekdays' => 'Trigger on weekdays',
'day_of_month' => 'Day of month',
'custom_cron' => 'Cron expression',
'custom_cron_hint' => 'Format: minute hour day month weekday',
'timezone_hint' => 'All times in :tz',
'weekday_names' => [
'sun' => 'Sun',
'mon' => 'Mon',
'tue' => 'Tue',
'wed' => 'Wed',
'thu' => 'Thu',
'fri' => 'Fri',
'sat' => 'Sat',
],
'summary' => [
'every_n_minutes' => 'Runs every minute|Runs every :count minutes',
'every_n_hours' => 'Runs every hour at minute :minute|Runs every :count hours at minute :minute',
'every_n_days' => 'Runs every day at :time|Runs every :count days at :time',
'weekly' => 'Runs every :days at :time',
'monthly' => 'Runs on day :day of every month at :time',
],
],
],
'generate' => [
'social_accounts' => 'Social accounts',
'social_accounts_empty' => 'No connected social accounts. Connect one first.',
'target_slide_count' => 'Slides to generate (for carousel-capable platforms)',
'prompt_template' => 'Prompt template',
'image_source' => 'Image source',
'image_sources' => [
'ai' => 'AI generated',
'unsplash' => 'Unsplash',
'none' => 'No image',
],
],
'delay' => [
'duration' => 'Duration',
'unit' => 'Unit',
'units' => [
'minutes' => 'Minutes',
'hours' => 'Hours',
'days' => 'Days',
],
],
'condition' => [
'field' => 'Field',
'operator' => 'Operator',
'operators' => [
'contains' => 'contains',
'not_contains' => 'not contains',
'equals' => 'equals',
'not_equals' => 'not equals',
'matches' => 'matches (regex)',
'greater_than' => 'greater than',
'less_than' => 'less than',
],
'value' => 'Value',
],
'publish' => [
'mode' => 'Mode',
'modes' => [
'now' => 'Publish now',
'scheduled' => 'Schedule',
'draft' => 'Save as draft',
],
'scheduled_offset' => 'Offset from trigger (minutes)',
],
'webhook' => [
'url' => 'URL',
'method' => 'Method',
'payload_template' => 'Payload template (JSON)',
],
'end' => [
'reason' => 'Reason (optional)',
'reason_placeholder' => 'e.g. Filtered out by condition',
],
'fetch_rss' => [
'feed_url' => 'Feed URL',
'feed_url_hint' => 'On first run, the watermark is set to "now" so historical items don\'t flood downstream nodes. Subsequent runs only see items newer than the previous poll.',
],
'http_request' => [
'url' => 'URL',
'method' => 'Method',
'auth_type' => 'Authentication',
'auth' => [
'none' => 'None (public)',
'bearer' => 'Bearer token',
'basic' => 'Basic auth',
'api_key' => 'API key header',
],
'bearer_token' => 'Bearer token',
'basic_username' => 'Username',
'basic_password' => 'Password',
'api_key_header' => 'Header name',
'api_key_value' => 'API key',
'body_template' => 'Body template (JSON)',
'polling_section' => 'Polling (optional)',
'polling_hint' => 'Leave blank to use the whole response as a single payload. Fill in to extract an array of items and spawn one run per item.',
'items_path' => 'Items path',
'item_key_path' => 'Item key path',
'item_date_path' => 'Item date path (optional)',
'item_date_path_hint' => 'JSON path to the item timestamp. When set, only items newer than the previous fetch are forwarded — prevents the first fetch from flooding downstream nodes.',
],
],
'delete' => [
'title' => 'Delete automation',
'description' => 'Are you sure you want to delete this automation? All runs and trigger items will also be removed. This action cannot be undone.',
'confirm' => 'Delete',
'cancel' => 'Cancel',
],
'flash' => [
'deleted' => 'Automation deleted successfully!',
],
'errors' => [
'no_active_social_accounts' => 'No active social accounts configured for this automation.',
'must_have_one_trigger' => 'Automation must have exactly one trigger node.',
'trigger_must_be_connected' => 'Trigger node must be connected to at least one node.',
'graph_contains_cycle' => 'Automation graph contains a cycle.',
'only_failed_can_retry' => 'Only failed runs can be retried.',
'no_generated_post' => 'No generated post found on run.',
'webhook_server_error' => 'Webhook server error.',
'node_no_longer_exists' => 'Node :node_id no longer exists in the automation.',
'no_trigger_connection' => 'No node connected to the Trigger node.',
],
];

View file

@ -3,6 +3,8 @@
declare(strict_types=1);
return [
'back' => 'Back',
'confirm_modal' => [
'cannot_be_undone' => 'This cannot be undone.',
'type' => 'Type',
@ -47,4 +49,9 @@
'clear' => 'Clear',
'close' => 'Close',
'loading_more' => 'Loading more...',
'actions' => [
'copy' => 'Copy',
'copied' => 'Copied',
],
];

View file

@ -26,6 +26,7 @@
],
'analytics' => 'Analytics',
'automations' => 'Automations',
'settings' => 'Settings',
'posts' => [

248
lang/es/automations.php Normal file
View file

@ -0,0 +1,248 @@
<?php
return [
'title' => 'Automatizaciones',
'default_name' => 'Nueva automatización',
'actions' => [
'new' => 'Nueva automatización',
'edit' => 'Editar',
'save' => 'Guardar',
'activate' => 'Activar',
'pause' => 'Pausar',
'delete' => 'Eliminar',
'retry' => 'Reintentar',
'add_node' => 'Agregar nodo',
'test' => 'Probar',
],
'test' => [
'title' => 'Ejecución de prueba',
'description' => 'Ejecuta la automatización de punta a punta usando un payload de disparo sintético. Útil para validar cada nodo sin esperar el cronograma o el feed real.',
'starting' => 'Iniciando ejecución de prueba…',
'in_progress' => 'En progreso',
'completed' => 'Completado',
'failed' => 'Fallido',
'waiting' => 'Esperando',
'close' => 'Cerrar',
'no_node_runs' => 'Esperando que el primer nodo comience…',
'node_input' => 'Entrada',
'node_output' => 'Salida',
'node_error' => 'Error',
'error_starting' => 'No se pudo iniciar la ejecución de prueba.',
'with_real_data' => 'Con datos reales',
'real_data_hint' => 'Esta prueba publicará posts, avanzará marcadores de polling y disparará efectos secundarios externos.',
'dry_badge' => 'Prueba seca',
],
'status' => [
'draft' => 'Borrador',
'active' => 'Activa',
'paused' => 'Pausada',
],
'index' => [
'empty_title' => 'Aún no hay automatizaciones',
'empty_description' => 'Crea tu primera automatización para empezar a publicar en piloto automático.',
'columns' => [
'name' => 'Nombre',
'status' => 'Estado',
'created' => 'Creada',
'actions' => 'Acciones',
],
],
'show' => [
'activated' => 'Activada',
'tabs' => [
'overview' => 'Resumen',
'runs' => 'Ejecuciones',
'trigger_items' => 'Elementos del disparador',
],
'canvas_placeholder' => 'Vista previa del canvas (solo lectura)',
'empty_runs' => 'Aún no hay ejecuciones.',
'empty_trigger_items' => 'Aún no hay elementos del disparador.',
'started' => 'Iniciada',
'run_label' => 'Ejecución',
],
'form' => [
'activate_error_fallback' => 'No se pudo activar la automatización.',
'pause_error_fallback' => 'No se pudo pausar la automatización.',
'save_error_fallback' => 'No se pudo guardar la automatización.',
'save_success' => 'Automatización guardada.',
'config_title' => 'Config. de :type',
'empty_canvas_title' => 'Empieza a construir tu automatización',
'empty_canvas_description' => 'Arrastra un nodo del panel izquierdo para empezar.',
'name_placeholder' => 'Automatización sin título',
],
'nodes' => [
'trigger' => 'Disparador',
'generate' => 'Generar',
'delay' => 'Retraso',
'condition' => 'Condición',
'publish' => 'Publicar',
'webhook' => 'Webhook',
'end' => 'Terminar',
'end_summary' => 'Termina la automatización aquí',
'fetch_rss' => 'Obtener RSS',
'http_request' => 'Petición HTTP',
],
'config' => [
'select_placeholder' => 'Selecciona…',
'trigger' => [
'type' => 'Tipo de disparador',
'types' => [
'schedule' => 'Programación',
'post_published' => 'Cuando un post se publica',
'post_scheduled' => 'Cuando un post se programa',
],
'post_published_hint' => 'Se ejecuta cada vez que un post en este workspace se publica. El post queda disponible en {{ trigger.post }} para los siguientes nodos.',
'post_scheduled_hint' => 'Se ejecuta cada vez que un post en este workspace se programa. El post queda disponible en {{ trigger.post }}.',
'schedule' => [
'field' => 'Intervalo de disparo',
'fields' => [
'minutes' => 'Minutos',
'hours' => 'Horas',
'days' => 'Días',
'weeks' => 'Semanas',
'months' => 'Meses',
'custom' => 'Personalizado (Cron)',
],
'minutes_interval' => 'Minutos entre disparos',
'hours_interval' => 'Horas entre disparos',
'days_interval' => 'Días entre disparos',
'hour' => 'Disparar a la hora',
'minute' => 'Disparar al minuto',
'weekdays' => 'Disparar en días',
'day_of_month' => 'Día del mes',
'custom_cron' => 'Expresión cron',
'custom_cron_hint' => 'Formato: minuto hora día mes día-de-semana',
'timezone_hint' => 'Todos los horarios en :tz',
'weekday_names' => [
'sun' => 'Dom',
'mon' => 'Lun',
'tue' => 'Mar',
'wed' => 'Mié',
'thu' => 'Jue',
'fri' => 'Vie',
'sat' => 'Sáb',
],
'summary' => [
'every_n_minutes' => 'Se ejecuta cada minuto|Se ejecuta cada :count minutos',
'every_n_hours' => 'Se ejecuta cada hora en el minuto :minute|Se ejecuta cada :count horas en el minuto :minute',
'every_n_days' => 'Se ejecuta cada día a las :time|Se ejecuta cada :count días a las :time',
'weekly' => 'Se ejecuta :days a las :time',
'monthly' => 'Se ejecuta el día :day de cada mes a las :time',
],
],
],
'generate' => [
'social_accounts' => 'Cuentas sociales',
'social_accounts_empty' => 'Sin cuentas sociales conectadas. Conecta una primero.',
'target_slide_count' => 'Diapositivas a generar (para plataformas con carrusel)',
'prompt_template' => 'Plantilla de prompt',
'image_source' => 'Fuente de imagen',
'image_sources' => [
'ai' => 'Generada con IA',
'unsplash' => 'Unsplash',
'none' => 'Sin imagen',
],
],
'delay' => [
'duration' => 'Duración',
'unit' => 'Unidad',
'units' => [
'minutes' => 'Minutos',
'hours' => 'Horas',
'days' => 'Días',
],
],
'condition' => [
'field' => 'Campo',
'operator' => 'Operador',
'operators' => [
'contains' => 'contiene',
'not_contains' => 'no contiene',
'equals' => 'es igual a',
'not_equals' => 'no es igual a',
'matches' => 'coincide (regex)',
'greater_than' => 'mayor que',
'less_than' => 'menor que',
],
'value' => 'Valor',
],
'publish' => [
'mode' => 'Modo',
'modes' => [
'now' => 'Publicar ahora',
'scheduled' => 'Programar',
'draft' => 'Guardar como borrador',
],
'scheduled_offset' => 'Diferencia desde el disparador (minutos)',
],
'webhook' => [
'url' => 'URL',
'method' => 'Método',
'payload_template' => 'Plantilla de payload (JSON)',
],
'end' => [
'reason' => 'Razón (opcional)',
'reason_placeholder' => 'p.ej. Filtrado por la condición',
],
'fetch_rss' => [
'feed_url' => 'URL del feed',
'feed_url_hint' => 'En la primera ejecución, el watermark se fija en "ahora" para no inundar los siguientes nodos con ítems históricos. Ejecuciones siguientes solo ven ítems nuevos.',
],
'http_request' => [
'url' => 'URL',
'method' => 'Método',
'auth_type' => 'Autenticación',
'auth' => [
'none' => 'Ninguna (pública)',
'bearer' => 'Bearer token',
'basic' => 'Basic auth',
'api_key' => 'Header de API key',
],
'bearer_token' => 'Bearer token',
'basic_username' => 'Usuario',
'basic_password' => 'Contraseña',
'api_key_header' => 'Nombre del header',
'api_key_value' => 'API key',
'body_template' => 'Plantilla del body (JSON)',
'polling_section' => 'Polling (opcional)',
'polling_hint' => 'Deja vacío para usar la respuesta completa como un solo payload. Rellena para extraer un array de ítems y disparar un run por ítem.',
'items_path' => 'Ruta de ítems',
'item_key_path' => 'Ruta de clave del ítem',
'item_date_path' => 'Ruta de fecha del ítem (opcional)',
'item_date_path_hint' => 'Ruta JSON al timestamp del ítem. Cuando se define, solo los ítems más nuevos que la última obtención se reenvían — evita que la primera obtención inunde los siguientes nodos.',
],
],
'delete' => [
'title' => 'Eliminar automatización',
'description' => '¿Estás seguro de que deseas eliminar esta automatización? Todas las ejecuciones y elementos del disparador también serán eliminados. Esta acción no se puede deshacer.',
'confirm' => 'Eliminar',
'cancel' => 'Cancelar',
],
'flash' => [
'deleted' => '¡Automatización eliminada correctamente!',
],
'errors' => [
'no_active_social_accounts' => 'No hay cuentas sociales activas configuradas para esta automatización.',
'must_have_one_trigger' => 'La automatización debe tener exactamente un nodo disparador.',
'trigger_must_be_connected' => 'El nodo disparador debe estar conectado a al menos un nodo.',
'graph_contains_cycle' => 'El grafo de la automatización contiene un ciclo.',
'only_failed_can_retry' => 'Solo se pueden reintentar ejecuciones fallidas.',
'no_generated_post' => 'No se encontró un post generado en la ejecución.',
'webhook_server_error' => 'Error del servidor del webhook.',
'node_no_longer_exists' => 'El nodo :node_id ya no existe en la automatización.',
'no_trigger_connection' => 'Ningún nodo está conectado al nodo disparador.',
],
];

View file

@ -3,6 +3,8 @@
declare(strict_types=1);
return [
'back' => 'Volver',
'confirm_modal' => [
'cannot_be_undone' => 'Esta acción no se puede deshacer.',
'type' => 'Escribe',
@ -47,4 +49,9 @@
'clear' => 'Limpiar',
'close' => 'Cerrar',
'loading_more' => 'Cargando más...',
'actions' => [
'copy' => 'Copiar',
'copied' => 'Copiado',
],
];

View file

@ -26,6 +26,7 @@
],
'analytics' => 'Analytics',
'automations' => 'Automatizaciones',
'settings' => 'Configuración',
'posts' => [

248
lang/pt-BR/automations.php Normal file
View file

@ -0,0 +1,248 @@
<?php
return [
'title' => 'Automações',
'default_name' => 'Nova automação',
'actions' => [
'new' => 'Nova automação',
'edit' => 'Editar',
'save' => 'Salvar',
'activate' => 'Ativar',
'pause' => 'Pausar',
'delete' => 'Excluir',
'retry' => 'Tentar novamente',
'add_node' => 'Adicionar nó',
'test' => 'Testar',
],
'test' => [
'title' => 'Execução de teste',
'description' => 'Executa a automação ponta a ponta usando um payload de gatilho sintético. Útil pra validar cada nó sem esperar o agendamento ou o feed real.',
'starting' => 'Iniciando execução de teste…',
'in_progress' => 'Em andamento',
'completed' => 'Concluído',
'failed' => 'Falhou',
'waiting' => 'Aguardando',
'close' => 'Fechar',
'no_node_runs' => 'Aguardando o primeiro nó começar…',
'node_input' => 'Entrada',
'node_output' => 'Saída',
'node_error' => 'Erro',
'error_starting' => 'Não foi possível iniciar a execução de teste.',
'with_real_data' => 'Com dados reais',
'real_data_hint' => 'Este teste vai publicar posts, avançar watermarks e disparar efeitos colaterais externos.',
'dry_badge' => 'Teste seco',
],
'status' => [
'draft' => 'Rascunho',
'active' => 'Ativa',
'paused' => 'Pausada',
],
'index' => [
'empty_title' => 'Nenhuma automação ainda',
'empty_description' => 'Crie sua primeira automação para começar a publicar no piloto automático.',
'columns' => [
'name' => 'Nome',
'status' => 'Status',
'created' => 'Criada em',
'actions' => 'Ações',
],
],
'show' => [
'activated' => 'Ativada',
'tabs' => [
'overview' => 'Visão geral',
'runs' => 'Execuções',
'trigger_items' => 'Itens do trigger',
],
'canvas_placeholder' => 'Pré-visualização do canvas (somente leitura)',
'empty_runs' => 'Nenhuma execução ainda.',
'empty_trigger_items' => 'Nenhum item de trigger ainda.',
'started' => 'Iniciada',
'run_label' => 'Execução',
],
'form' => [
'activate_error_fallback' => 'Não foi possível ativar a automação.',
'pause_error_fallback' => 'Não foi possível pausar a automação.',
'save_error_fallback' => 'Não foi possível salvar a automação.',
'save_success' => 'Automação salva.',
'config_title' => 'Configuração :type',
'empty_canvas_title' => 'Comece a construir sua automação',
'empty_canvas_description' => 'Arraste um nó do painel esquerdo para começar.',
'name_placeholder' => 'Automação sem título',
],
'nodes' => [
'trigger' => 'Trigger',
'generate' => 'Gerar',
'delay' => 'Esperar',
'condition' => 'Condição',
'publish' => 'Publicar',
'webhook' => 'Webhook',
'end' => 'Encerrar',
'end_summary' => 'Encerra a automação aqui',
'fetch_rss' => 'Buscar RSS',
'http_request' => 'Requisição HTTP',
],
'config' => [
'select_placeholder' => 'Selecione…',
'trigger' => [
'type' => 'Tipo de trigger',
'types' => [
'schedule' => 'Agendamento',
'post_published' => 'Quando um post é publicado',
'post_scheduled' => 'Quando um post é agendado',
],
'post_published_hint' => 'Roda toda vez que algum post nesta workspace é publicado. O post fica disponível em {{ trigger.post }} pros próximos nós.',
'post_scheduled_hint' => 'Roda toda vez que algum post nesta workspace é agendado. O post fica disponível em {{ trigger.post }}.',
'schedule' => [
'field' => 'Intervalo de disparo',
'fields' => [
'minutes' => 'Minutos',
'hours' => 'Horas',
'days' => 'Dias',
'weeks' => 'Semanas',
'months' => 'Meses',
'custom' => 'Personalizado (Cron)',
],
'minutes_interval' => 'Minutos entre disparos',
'hours_interval' => 'Horas entre disparos',
'days_interval' => 'Dias entre disparos',
'hour' => 'Disparar na hora',
'minute' => 'Disparar no minuto',
'weekdays' => 'Disparar nos dias',
'day_of_month' => 'Dia do mês',
'custom_cron' => 'Expressão cron',
'custom_cron_hint' => 'Formato: minuto hora dia mês dia-da-semana',
'timezone_hint' => 'Todos os horários em :tz',
'weekday_names' => [
'sun' => 'Dom',
'mon' => 'Seg',
'tue' => 'Ter',
'wed' => 'Qua',
'thu' => 'Qui',
'fri' => 'Sex',
'sat' => 'Sáb',
],
'summary' => [
'every_n_minutes' => 'Roda a cada minuto|Roda a cada :count minutos',
'every_n_hours' => 'Roda a cada hora no minuto :minute|Roda a cada :count horas no minuto :minute',
'every_n_days' => 'Roda todo dia às :time|Roda a cada :count dias às :time',
'weekly' => 'Roda :days às :time',
'monthly' => 'Roda no dia :day de cada mês às :time',
],
],
],
'generate' => [
'social_accounts' => 'Contas sociais',
'social_accounts_empty' => 'Nenhuma conta social conectada. Conecte uma primeiro.',
'target_slide_count' => 'Slides a gerar (para plataformas com carrossel)',
'prompt_template' => 'Template do prompt',
'image_source' => 'Origem da imagem',
'image_sources' => [
'ai' => 'Gerada por IA',
'unsplash' => 'Unsplash',
'none' => 'Sem imagem',
],
],
'delay' => [
'duration' => 'Duração',
'unit' => 'Unidade',
'units' => [
'minutes' => 'Minutos',
'hours' => 'Horas',
'days' => 'Dias',
],
],
'condition' => [
'field' => 'Campo',
'operator' => 'Operador',
'operators' => [
'contains' => 'contém',
'not_contains' => 'não contém',
'equals' => 'igual a',
'not_equals' => 'diferente de',
'matches' => 'corresponde (regex)',
'greater_than' => 'maior que',
'less_than' => 'menor que',
],
'value' => 'Valor',
],
'publish' => [
'mode' => 'Modo',
'modes' => [
'now' => 'Publicar agora',
'scheduled' => 'Agendar',
'draft' => 'Salvar como rascunho',
],
'scheduled_offset' => 'Atraso a partir do trigger (minutos)',
],
'webhook' => [
'url' => 'URL',
'method' => 'Método',
'payload_template' => 'Template do payload (JSON)',
],
'end' => [
'reason' => 'Motivo (opcional)',
'reason_placeholder' => 'ex: Filtrado pela condição',
],
'fetch_rss' => [
'feed_url' => 'URL do feed',
'feed_url_hint' => 'Na primeira execução, o watermark é setado pra "agora" pra não inundar os próximos nós com items históricos. Execuções seguintes só veem items novos.',
],
'http_request' => [
'url' => 'URL',
'method' => 'Método',
'auth_type' => 'Autenticação',
'auth' => [
'none' => 'Nenhuma (público)',
'bearer' => 'Bearer token',
'basic' => 'Basic auth',
'api_key' => 'Header de API key',
],
'bearer_token' => 'Bearer token',
'basic_username' => 'Usuário',
'basic_password' => 'Senha',
'api_key_header' => 'Nome do header',
'api_key_value' => 'API key',
'body_template' => 'Template do body (JSON)',
'polling_section' => 'Polling (opcional)',
'polling_hint' => 'Deixe vazio para usar a resposta inteira como payload único. Preencha para extrair um array de itens e disparar um run por item.',
'items_path' => 'Caminho dos itens',
'item_key_path' => 'Caminho da chave do item',
'item_date_path' => 'Caminho da data do item (opcional)',
'item_date_path_hint' => 'Caminho JSON pro timestamp do item. Quando definido, só items mais novos que a última busca são encaminhados — evita que a primeira busca inunde os próximos nós.',
],
],
'delete' => [
'title' => 'Excluir automação',
'description' => 'Tem certeza que deseja excluir esta automação? Todas as execuções e itens de gatilho também serão removidos. Esta ação não pode ser desfeita.',
'confirm' => 'Excluir',
'cancel' => 'Cancelar',
],
'flash' => [
'deleted' => 'Automação excluída com sucesso!',
],
'errors' => [
'no_active_social_accounts' => 'Nenhuma conta social ativa configurada para esta automação.',
'must_have_one_trigger' => 'A automação precisa ter exatamente um nó de trigger.',
'trigger_must_be_connected' => 'O nó de trigger precisa estar conectado a pelo menos um nó.',
'graph_contains_cycle' => 'O grafo da automação contém um ciclo.',
'only_failed_can_retry' => 'Apenas execuções que falharam podem ser repetidas.',
'no_generated_post' => 'Nenhum post gerado encontrado para esta execução.',
'webhook_server_error' => 'Erro no servidor do webhook.',
'node_no_longer_exists' => 'O nó :node_id não existe mais nesta automação.',
'no_trigger_connection' => 'Nenhum nó conectado ao nó de trigger.',
],
];

View file

@ -3,6 +3,8 @@
declare(strict_types=1);
return [
'back' => 'Voltar',
'confirm_modal' => [
'cannot_be_undone' => 'Esta ação não pode ser desfeita.',
'type' => 'Digite',
@ -47,4 +49,9 @@
'clear' => 'Limpar',
'close' => 'Fechar',
'loading_more' => 'Carregando mais...',
'actions' => [
'copy' => 'Copiar',
'copied' => 'Copiado',
],
];

View file

@ -26,6 +26,7 @@
],
'analytics' => 'Analytics',
'automations' => 'Automações',
'settings' => 'Configurações',
'posts' => [

168
package-lock.json generated
View file

@ -4,17 +4,21 @@
"requires": true,
"packages": {
"": {
"name": "trypost",
"dependencies": {
"@inertiajs/vue3": "^3.0.0",
"@tabler/icons-vue": "^3.36.1",
"@tailwindcss/typography": "^0.5.19",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.3",
"@vue-flow/core": "^1.48.2",
"@vue-flow/minimap": "^1.5.4",
"@vueuse/core": "^12.8.2",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.19",
"embla-carousel-vue": "^8.6.0",
"highlight.js": "^11.11.1",
"laravel-vite-plugin": "^2.0.0",
"laravel-vue-i18n": "^2.8.0",
"maska": "^3.2.0",
@ -3523,6 +3527,150 @@
"vscode-uri": "^3.0.8"
}
},
"node_modules/@vue-flow/background": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@vue-flow/background/-/background-1.3.2.tgz",
"integrity": "sha512-eJPhDcLj1wEo45bBoqTXw1uhl0yK2RaQGnEINqvvBsAFKh/camHJd5NPmOdS1w+M9lggc9igUewxaEd3iCQX2w==",
"license": "MIT",
"peerDependencies": {
"@vue-flow/core": "^1.23.0",
"vue": "^3.3.0"
}
},
"node_modules/@vue-flow/controls": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@vue-flow/controls/-/controls-1.1.3.tgz",
"integrity": "sha512-XCf+G+jCvaWURdFlZmOjifZGw3XMhN5hHlfMGkWh9xot+9nH9gdTZtn+ldIJKtarg3B21iyHU8JjKDhYcB6JMw==",
"license": "MIT",
"peerDependencies": {
"@vue-flow/core": "^1.23.0",
"vue": "^3.3.0"
}
},
"node_modules/@vue-flow/core": {
"version": "1.48.2",
"resolved": "https://registry.npmjs.org/@vue-flow/core/-/core-1.48.2.tgz",
"integrity": "sha512-raxhgKWE+G/mcEvXJjGFUDYW9rAI3GOtiHR3ZkNpwBWuIaCC1EYiBmKGwJOoNzVFgwO7COgErnK7i08i287AFA==",
"license": "MIT",
"dependencies": {
"@vueuse/core": "^10.5.0",
"d3-drag": "^3.0.0",
"d3-interpolate": "^3.0.1",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0"
},
"peerDependencies": {
"vue": "^3.3.0"
}
},
"node_modules/@vue-flow/core/node_modules/@types/web-bluetooth": {
"version": "0.0.20",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
"integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==",
"license": "MIT"
},
"node_modules/@vue-flow/core/node_modules/@vueuse/core": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz",
"integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.20",
"@vueuse/metadata": "10.11.1",
"@vueuse/shared": "10.11.1",
"vue-demi": ">=0.14.8"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vue-flow/core/node_modules/@vueuse/core/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@vue-flow/core/node_modules/@vueuse/metadata": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz",
"integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vue-flow/core/node_modules/@vueuse/shared": {
"version": "10.11.1",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz",
"integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==",
"license": "MIT",
"dependencies": {
"vue-demi": ">=0.14.8"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vue-flow/core/node_modules/@vueuse/shared/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@vue-flow/minimap": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@vue-flow/minimap/-/minimap-1.5.4.tgz",
"integrity": "sha512-l4C+XTAXnRxsRpUdN7cAVFBennC1sVRzq4bDSpVK+ag7tdMczAnhFYGgbLkUw3v3sY6gokyWwMl8CDonp8eB2g==",
"license": "MIT",
"dependencies": {
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0"
},
"peerDependencies": {
"@vue-flow/core": "^1.23.0",
"vue": "^3.3.0"
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.26.tgz",
@ -4449,7 +4597,6 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
@ -4485,7 +4632,6 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
@ -4495,7 +4641,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"dev": true,
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
@ -4535,7 +4680,6 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
@ -4628,7 +4772,6 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"dev": true,
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
@ -4764,7 +4907,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
@ -4813,7 +4955,6 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
@ -4823,7 +4964,6 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
@ -4843,7 +4983,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"dev": true,
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
@ -6457,6 +6596,15 @@
"he": "bin/he"
}
},
"node_modules/highlight.js": {
"version": "11.11.1",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",

View file

@ -41,12 +41,17 @@
"@inertiajs/vue3": "^3.0.0",
"@tabler/icons-vue": "^3.36.1",
"@tailwindcss/typography": "^0.5.19",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.3",
"@vue-flow/core": "^1.48.2",
"@vue-flow/minimap": "^1.5.4",
"@vueuse/core": "^12.8.2",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.19",
"embla-carousel-vue": "^8.6.0",
"highlight.js": "^11.11.1",
"laravel-vite-plugin": "^2.0.0",
"laravel-vue-i18n": "^2.8.0",
"maska": "^3.2.0",

View file

@ -164,3 +164,121 @@ .h3 {
.trypost-container {
@apply relative max-w-7xl w-full flex-1 mx-auto flex flex-col justify-center;
}
/* Automations canvas nodes TryPost brutalist identity:
thick ink borders, hard offset shadow, warm card background, colored header
strip that fills the top of the card with the accent tint. */
.automation-node {
position: relative;
min-width: 230px;
background: var(--card);
border: 2px solid var(--foreground);
border-radius: 14px;
box-shadow: 3px 3px 0 var(--foreground);
transition: transform 120ms ease, box-shadow 120ms ease;
}
.automation-node:hover {
transform: translate(-1px, -1px);
box-shadow: 4px 4px 0 var(--foreground);
}
.automation-node--wide {
min-width: 260px;
}
.automation-node.is-selected {
transform: translate(-2px, -2px);
box-shadow: 5px 5px 0 #7c3aed;
}
.automation-node__header {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.625rem 0.875rem;
border-bottom: 2px solid var(--foreground);
border-top-left-radius: 12px;
border-top-right-radius: 12px;
}
.automation-node__icon-tile {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 2px solid var(--foreground);
border-radius: 8px;
flex-shrink: 0;
transform: rotate(-3deg);
}
/* Header accent tint mirrors the icon tile color, applied to the whole header
strip so the node type is identifiable at a glance from the canvas. */
.automation-node__icon-tile--violet { background: #ede9fe; color: #5b21b6; }
.automation-node__icon-tile--blue { background: #dbeafe; color: #1d4ed8; }
.automation-node__icon-tile--amber { background: #fef3c7; color: #92400e; }
.automation-node__icon-tile--rose { background: #ffe4e6; color: #be123c; }
.automation-node__icon-tile--emerald { background: #d1fae5; color: #047857; }
.automation-node__icon-tile--slate { background: #e2e8f0; color: #334155; }
.automation-node__icon-tile--zinc { background: #e4e4e7; color: #27272a; }
.automation-node--accent-violet .automation-node__header { background: #f5f3ff; }
.automation-node--accent-blue .automation-node__header { background: #eff6ff; }
.automation-node--accent-amber .automation-node__header { background: #fffbeb; }
.automation-node--accent-rose .automation-node__header { background: #fff1f2; }
.automation-node--accent-emerald .automation-node__header { background: #ecfdf5; }
.automation-node--accent-slate .automation-node__header { background: #f1f5f9; }
.automation-node--accent-zinc .automation-node__header { background: #f4f4f5; }
.automation-node__title {
font-weight: 700;
font-size: 0.875rem;
color: var(--foreground);
line-height: 1.2;
letter-spacing: -0.005em;
}
.automation-node__summary {
padding: 0.625rem 0.875rem 0.75rem 0.875rem;
font-size: 0.75rem;
font-weight: 500;
color: color-mix(in srgb, var(--foreground) 70%, transparent);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
}
.automation-node__branches {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.375rem 0.875rem 0.625rem 0.875rem;
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.automation-node__branch--yes { color: #047857; }
.automation-node__branch--no { color: #be123c; }
/* JsonViewer — GitHub Light palette for highlight.js JSON output. */
.json-viewer__body {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
margin: 0;
background: #ffffff;
color: #24292f;
}
.json-viewer .hljs-attr { color: #0550ae; }
.json-viewer .hljs-string { color: #0a3069; }
.json-viewer .hljs-number { color: #0550ae; }
.json-viewer .hljs-literal { color: #cf222e; }
.json-viewer .hljs-punctuation { color: #57606a; }
.json-viewer .hljs-comment { color: #6e7781; font-style: italic; }

View file

@ -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<NavItem[]>(() => [
href: analytics.url(),
icon: IconChartBar,
},
{
title: trans('sidebar.automations'),
href: automations.url(),
icon: IconBolt,
},
]);
const postsNavItems = computed<NavItem[]>(() => [

View file

@ -0,0 +1,51 @@
<script setup lang="ts">
import { IconCheck, IconCopy } from '@tabler/icons-vue';
import hljs from 'highlight.js/lib/core';
import jsonLang from 'highlight.js/lib/languages/json';
import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
import { copyToClipboard } from '@/lib/utils';
hljs.registerLanguage('json', jsonLang);
const props = defineProps<{ value: unknown }>();
const serialized = computed(() => {
if (props.value === null || props.value === undefined) return '';
try {
return JSON.stringify(props.value, null, 2);
} catch {
return String(props.value);
}
});
const highlighted = computed(() => {
if (serialized.value === '') return '';
return hljs.highlight(serialized.value, { language: 'json' }).value;
});
const justCopied = ref(false);
const handleCopy = async () => {
await copyToClipboard(serialized.value, trans('common.actions.copied'));
justCopied.value = true;
setTimeout(() => { justCopied.value = false; }, 1500);
};
</script>
<template>
<div class="json-viewer overflow-hidden rounded-lg border-2 border-foreground">
<div class="flex items-center justify-end border-b-2 border-foreground/15 bg-card px-2 py-1.5">
<button
type="button"
class="inline-flex h-7 items-center gap-1.5 rounded-md border-2 border-foreground bg-card px-2 text-xs font-bold uppercase tracking-wider shadow-[1px_1px_0_var(--foreground)] transition hover:-translate-x-px hover:-translate-y-px hover:shadow-[2px_2px_0_var(--foreground)] active:translate-x-0 active:translate-y-0 active:shadow-[0_0_0_var(--foreground)]"
@click="handleCopy"
>
<component :is="justCopied ? IconCheck : IconCopy" class="size-3.5" stroke-width="2.5" />
{{ justCopied ? $t('common.actions.copied') : $t('common.actions.copy') }}
</button>
</div>
<pre class="json-viewer__body overflow-x-auto p-3 text-xs leading-relaxed"><code class="hljs language-json" v-html="highlighted" /></pre>
</div>
</template>

View file

@ -0,0 +1,54 @@
<script setup lang="ts">
import { getSmoothStepPath, Position } from '@vue-flow/core';
import { computed } from 'vue';
const props = defineProps<{
sourceX: number;
sourceY: number;
sourcePosition?: Position;
targetX: number;
targetY: number;
targetPosition?: Position;
}>();
const pathData = computed(() => {
const [path] = getSmoothStepPath({
sourceX: props.sourceX,
sourceY: props.sourceY,
sourcePosition: props.sourcePosition ?? Position.Right,
targetX: props.targetX,
targetY: props.targetY,
targetPosition: props.targetPosition ?? Position.Left,
borderRadius: 32,
offset: 24,
});
return path;
});
</script>
<template>
<g>
<defs>
<marker
id="automation-connection-arrow"
viewBox="-10 -10 20 20"
refX="0"
refY="0"
markerWidth="9"
markerHeight="9"
markerUnits="strokeWidth"
orient="auto-start-reverse"
>
<polyline
points="-5,-4 0,0 -5,4 -5,-4"
style="stroke: #0a0a0a; fill: #0a0a0a; stroke-width: 1; stroke-linecap: round; stroke-linejoin: round;"
/>
</marker>
</defs>
<path
class="vue-flow__connection-path"
:d="pathData"
marker-end="url(#automation-connection-arrow)"
/>
</g>
</template>

View file

@ -0,0 +1,225 @@
<script setup lang="ts">
import { IconAlertCircle, IconChevronRight, IconCircleCheck, IconCircleDot, IconLoader2, IconX } from '@tabler/icons-vue';
import { ref } from 'vue';
import { toast } from 'vue-sonner';
import { trans } from 'laravel-vue-i18n';
import JsonViewer from '@/components/JsonViewer.vue';
import { useAutomationEcho } from '@/composables/echo/useAutomationEcho';
import { Button } from '@/components/ui/button';
import { test as testAutomation } from '@/routes/app/automations';
import { showRun as showRunRoute } from '@/actions/App/Http/Controllers/App/AutomationController';
interface NodeRun {
id: string;
node_id: string;
node_type: string;
status: string;
input: Record<string, unknown> | null;
output: Record<string, unknown> | null;
error: { message?: string } | null;
started_at: string | null;
finished_at: string | null;
}
interface Run {
id: string;
status: string;
context: Record<string, unknown> | null;
error: { message?: string } | null;
started_at: string | null;
finished_at: string | null;
is_dry_run: boolean;
}
const props = defineProps<{ automationId: string; withRealData?: boolean }>();
const open = defineModel<boolean>('open', { default: false });
const isStarting = ref(false);
const run = ref<Run | null>(null);
const nodeRuns = ref<NodeRun[]>([]);
const activeRunId = ref<string | null>(null);
const fetchRun = async (runId: string): Promise<void> => {
try {
const response = await fetch(showRunRoute.url({ automation: props.automationId, run: runId }), {
headers: { Accept: 'application/json' },
});
if (!response.ok) return;
const data = await response.json();
run.value = data.run;
nodeRuns.value = data.node_runs;
} catch {
// Silent on refresh failures the next broadcast will retry.
}
};
// Subscribe once to the automation's private channel. The backend broadcasts
// a tiny `{ run_id, status }` payload on every run/node update; we refetch the
// full state only when the event is for our active run. Zero polling.
useAutomationEcho<{ run_id: string; status: string }>(
props.automationId,
'.automation.run.updated',
(payload) => {
if (payload.run_id === activeRunId.value) {
fetchRun(payload.run_id);
}
},
);
const start = async () => {
if (isStarting.value) return;
isStarting.value = true;
run.value = null;
nodeRuns.value = [];
activeRunId.value = null;
try {
const response = await fetch(testAutomation.url(props.automationId), {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content ?? '',
},
body: JSON.stringify({ with_real_data: props.withRealData ?? false }),
});
if (!response.ok) {
throw new Error('start failed');
}
const { run_id: runId } = await response.json();
activeRunId.value = runId;
await fetchRun(runId);
} catch {
toast.error(trans('automations.test.error_starting'));
open.value = false;
} finally {
isStarting.value = false;
}
};
// Parent triggers runs imperatively via the template ref so every click on the
// Test button kicks off a fresh execution including the first one, since the
// parent calls `start()` right after toggling the panel open.
defineExpose({ start });
const close = () => { open.value = false; };
const runStatusIcon = (status: string) => {
if (status === 'completed') return IconCircleCheck;
if (status === 'failed') return IconAlertCircle;
if (status === 'running') return IconLoader2;
return IconCircleDot;
};
const statusLabel = (status: string): string => {
const map: Record<string, string> = {
running: trans('automations.test.in_progress'),
completed: trans('automations.test.completed'),
failed: trans('automations.test.failed'),
waiting: trans('automations.test.waiting'),
};
return map[status] ?? status;
};
const nodeStatusIcon = (status: string) => {
if (status === 'completed') return IconCircleCheck;
if (status === 'failed') return IconAlertCircle;
if (status === 'running') return IconLoader2;
return IconCircleDot;
};
</script>
<template>
<aside class="flex w-[36rem] flex-shrink-0 flex-col gap-5 overflow-y-auto border-l-2 border-foreground/10 px-5 pt-5 pb-12">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-2">
<h2 class="text-lg font-bold">{{ $t('automations.test.title') }}</h2>
<span
v-if="run?.is_dry_run"
class="inline-flex -rotate-3 items-center rounded-md border-2 border-foreground bg-amber-200 px-2 py-0.5 text-[10px] font-black uppercase tracking-widest text-foreground shadow-[2px_2px_0_var(--foreground)]"
>
{{ $t('automations.test.dry_badge') }}
</span>
</div>
<p class="mt-1 text-sm text-foreground/60">
{{ run?.is_dry_run === false ? $t('automations.test.real_data_hint') : $t('automations.test.description') }}
</p>
</div>
<Button variant="ghost" size="icon-sm" @click="close">
<IconX class="size-5" />
</Button>
</div>
<div v-if="run === null && isStarting" class="flex items-center gap-2.5 text-sm font-medium text-foreground/70">
<IconLoader2 class="size-5 animate-spin" />
{{ $t('automations.test.starting') }}
</div>
<div v-if="run" class="flex items-center gap-3 rounded-xl border-2 border-foreground bg-card p-4 shadow-[3px_3px_0_var(--foreground)]">
<div
:class="[
'inline-flex size-10 -rotate-3 shrink-0 items-center justify-center rounded-xl border-2 border-foreground shadow-2xs',
run.status === 'completed' && 'bg-emerald-200 text-emerald-900',
run.status === 'failed' && 'bg-rose-200 text-rose-900',
run.status === 'running' && 'bg-amber-200 text-amber-900',
!['completed', 'failed', 'running'].includes(run.status) && 'bg-zinc-200 text-zinc-900',
]"
>
<component :is="runStatusIcon(run.status)" :class="['size-5', run.status === 'running' && 'animate-spin']" stroke-width="2.5" />
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-black uppercase tracking-wider text-foreground/60">{{ $t('automations.test.title') }}</p>
<p class="text-base font-bold">{{ statusLabel(run.status) }}</p>
<p v-if="run.error" class="mt-1 text-sm text-rose-700">{{ run.error.message }}</p>
</div>
</div>
<div v-if="run && nodeRuns.length === 0" class="rounded-xl border-2 border-dashed border-foreground/25 bg-card/40 p-8 text-center text-sm font-medium text-foreground/60">
<IconLoader2 class="mx-auto mb-2 size-6 animate-spin" />
{{ $t('automations.test.no_node_runs') }}
</div>
<ul v-if="nodeRuns.length > 0" class="space-y-3">
<li
v-for="nodeRun in nodeRuns"
:key="nodeRun.id"
class="rounded-xl border-2 border-foreground bg-card shadow-[3px_3px_0_var(--foreground)]"
>
<div class="flex items-center gap-3 p-4">
<div
:class="[
'inline-flex size-10 -rotate-3 shrink-0 items-center justify-center rounded-xl border-2 border-foreground shadow-2xs',
nodeRun.status === 'completed' && 'bg-emerald-200 text-emerald-900',
nodeRun.status === 'failed' && 'bg-rose-200 text-rose-900',
nodeRun.status === 'running' && 'bg-amber-200 text-amber-900',
!['completed', 'failed', 'running'].includes(nodeRun.status) && 'bg-zinc-200 text-zinc-900',
]"
>
<component :is="nodeStatusIcon(nodeRun.status)" :class="['size-5', nodeRun.status === 'running' && 'animate-spin']" stroke-width="2.5" />
</div>
<div class="min-w-0 flex-1">
<p class="text-base font-bold capitalize leading-tight">{{ nodeRun.node_type.replace('_', ' ') }}</p>
<p class="text-xs font-semibold uppercase tracking-wider text-foreground/50">{{ statusLabel(nodeRun.status) }}</p>
</div>
</div>
<div v-if="nodeRun.error || nodeRun.output" class="border-t-2 border-foreground/10 px-4 pb-4 pt-3">
<p v-if="nodeRun.error" class="rounded-lg border-2 border-rose-700 bg-rose-50 p-3 text-sm font-medium text-rose-800">
<span class="font-black uppercase text-xs tracking-wider">{{ $t('automations.test.node_error') }}:</span>
<span class="ml-1">{{ nodeRun.error.message }}</span>
</p>
<details v-if="nodeRun.output" class="group" :class="{ 'mt-3': nodeRun.error }">
<summary class="flex cursor-pointer items-center gap-1.5 text-xs font-black uppercase tracking-wider text-foreground/60 hover:text-foreground">
<IconChevronRight class="size-4 transition-transform group-open:rotate-90" stroke-width="2.5" />
{{ $t('automations.test.node_output') }}
</summary>
<JsonViewer :value="nodeRun.output" class="mt-2" />
</details>
</div>
</li>
</ul>
</aside>
</template>

View file

@ -0,0 +1,68 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import InputError from '@/components/InputError.vue';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
interface ConditionConfig {
field: string;
operator: string;
value: string;
}
const props = defineProps<{
data: Record<string, unknown>;
errors?: Record<string, string>;
}>();
const emit = defineEmits<{ update: [Record<string, unknown>] }>();
const local = ref<ConditionConfig>({
field: (props.data.field as string) ?? '',
operator: (props.data.operator as string) ?? 'contains',
value: (props.data.value as string) ?? '',
});
watch(local, (val) => emit('update', val), { deep: true });
</script>
<template>
<div class="space-y-3">
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.condition.field') }}</label>
<Input v-model="local.field" placeholder="{{ trigger.title }}" />
<InputError :message="errors?.field" class="mt-1" />
</div>
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.condition.operator') }}</label>
<Select v-model="local.operator">
<SelectTrigger class="w-full">
<SelectValue :placeholder="$t('automations.config.select_placeholder')" />
</SelectTrigger>
<SelectContent>
<SelectItem value="contains">{{ $t('automations.config.condition.operators.contains') }}</SelectItem>
<SelectItem value="not_contains">{{ $t('automations.config.condition.operators.not_contains') }}</SelectItem>
<SelectItem value="equals">{{ $t('automations.config.condition.operators.equals') }}</SelectItem>
<SelectItem value="not_equals">{{ $t('automations.config.condition.operators.not_equals') }}</SelectItem>
<SelectItem value="matches">{{ $t('automations.config.condition.operators.matches') }}</SelectItem>
<SelectItem value="greater_than">{{ $t('automations.config.condition.operators.greater_than') }}</SelectItem>
<SelectItem value="less_than">{{ $t('automations.config.condition.operators.less_than') }}</SelectItem>
</SelectContent>
</Select>
<InputError :message="errors?.operator" class="mt-1" />
</div>
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.condition.value') }}</label>
<Input v-model="local.value" placeholder="keyword" />
<InputError :message="errors?.value" class="mt-1" />
</div>
</div>
</template>

View file

@ -0,0 +1,56 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import InputError from '@/components/InputError.vue';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
interface DelayConfig {
duration: number;
unit: 'minutes' | 'hours' | 'days';
}
const props = defineProps<{
data: Record<string, unknown>;
errors?: Record<string, string>;
}>();
const emit = defineEmits<{ update: [Record<string, unknown>] }>();
const local = ref<DelayConfig>({
duration: (props.data.duration as number) ?? 1,
unit: (props.data.unit as DelayConfig['unit']) ?? 'hours',
});
watch(local, (val) => emit('update', val), { deep: true });
</script>
<template>
<div class="space-y-3">
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.delay.duration') }}</label>
<Input type="number" v-model.number="local.duration" placeholder="1" />
<InputError :message="errors?.duration" class="mt-1" />
</div>
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.delay.unit') }}</label>
<Select v-model="local.unit">
<SelectTrigger class="w-full">
<SelectValue :placeholder="$t('automations.config.select_placeholder')" />
</SelectTrigger>
<SelectContent>
<SelectItem value="minutes">{{ $t('automations.config.delay.units.minutes') }}</SelectItem>
<SelectItem value="hours">{{ $t('automations.config.delay.units.hours') }}</SelectItem>
<SelectItem value="days">{{ $t('automations.config.delay.units.days') }}</SelectItem>
</SelectContent>
</Select>
<InputError :message="errors?.unit" class="mt-1" />
</div>
</div>
</template>

View file

@ -0,0 +1,33 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import InputError from '@/components/InputError.vue';
import { Textarea } from '@/components/ui/textarea';
interface EndConfig {
reason: string;
}
const props = defineProps<{
data: Record<string, unknown>;
errors?: Record<string, string>;
}>();
const emit = defineEmits<{ update: [Record<string, unknown>] }>();
const local = ref<EndConfig>({
reason: (props.data.reason as string) ?? '',
});
watch(local, (val) => emit('update', val), { deep: true });
</script>
<template>
<div class="space-y-3">
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.end.reason') }}</label>
<Textarea v-model="local.reason" :placeholder="$t('automations.config.end.reason_placeholder')" :rows="3" />
<InputError :message="errors?.reason" class="mt-1" />
</div>
<p class="text-xs text-muted-foreground">{{ $t('automations.nodes.end_summary') }}</p>
</div>
</template>

View file

@ -0,0 +1,33 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import InputError from '@/components/InputError.vue';
import { Input } from '@/components/ui/input';
interface FetchRssConfig {
feed_url: string;
}
const props = defineProps<{
data: Record<string, unknown>;
errors?: Record<string, string>;
}>();
const emit = defineEmits<{ update: [Record<string, unknown>] }>();
const local = ref<FetchRssConfig>({
feed_url: (props.data.feed_url as string) ?? '',
});
watch(local, (val) => emit('update', val), { deep: true });
</script>
<template>
<div class="space-y-3">
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.fetch_rss.feed_url') }}</label>
<Input v-model="local.feed_url" placeholder="https://example.com/feed.xml" />
<InputError :message="errors?.feed_url" class="mt-1" />
<p class="mt-1 text-xs text-foreground/50">{{ $t('automations.config.fetch_rss.feed_url_hint') }}</p>
</div>
</div>
</template>

View file

@ -0,0 +1,337 @@
<script setup lang="ts">
import { usePage } from '@inertiajs/vue3';
import { IconCheck } from '@tabler/icons-vue';
import { computed, ref, watch } from 'vue';
import InputError from '@/components/InputError.vue';
import FacebookSettings from '@/components/posts/editor/FacebookSettings.vue';
import InstagramSettings from '@/components/posts/editor/InstagramSettings.vue';
import LinkedInSettings from '@/components/posts/editor/LinkedInSettings.vue';
import PinterestSettings from '@/components/posts/editor/PinterestSettings.vue';
import TikTokSettings from '@/components/posts/editor/TikTokSettings.vue';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { ContentType } from '@/types/content-type';
import { Platform } from '@/types/platform';
import type { PinterestBoard } from '@/types';
interface SocialAccount {
id: string;
platform: string;
display_name: string;
username: string;
avatar_url: string | null;
}
interface TikTokCreatorInfo {
creator_nickname: string | null;
creator_username: string | null;
creator_avatar_url: string | null;
privacy_level_options: string[];
comment_disabled: boolean;
duet_disabled: boolean;
stitch_disabled: boolean;
max_video_post_duration_sec: number | null;
}
interface GenerateAccount {
social_account_id: string;
content_type: string;
meta: Record<string, any>;
}
interface GenerateConfig {
accounts: GenerateAccount[];
target_slide_count?: number;
prompt_template: string;
image_source: 'ai' | 'unsplash' | 'none';
}
const props = defineProps<{
data: Record<string, unknown>;
errors?: Record<string, string>;
}>();
const emit = defineEmits<{ update: [Record<string, unknown>] }>();
const page = usePage();
const socialAccounts = computed<SocialAccount[]>(() => {
const raw = page.props.socialAccounts as { data?: SocialAccount[] } | SocialAccount[] | undefined;
if (!raw) return [];
return Array.isArray(raw) ? (raw as SocialAccount[]) : ((raw as { data: SocialAccount[] }).data ?? []);
});
const platformConfigs = computed<Record<string, any>>(() => {
const raw = page.props.platformConfigs as Record<string, any> | undefined;
return raw ?? {};
});
const pinterestBoards = computed<Record<string, PinterestBoard[]>>(() => {
const raw = page.props.pinterestBoards as Record<string, PinterestBoard[]> | undefined;
return raw ?? {};
});
const tiktokCreatorInfos = computed<Record<string, TikTokCreatorInfo>>(() => {
const raw = page.props.tiktokCreatorInfos as Record<string, TikTokCreatorInfo> | null | undefined;
return raw ?? {};
});
const defaultContentTypeFor = (platform: string): string => {
switch (platform) {
case Platform.Instagram:
case Platform.InstagramFacebook:
return ContentType.InstagramFeed;
case Platform.Facebook:
return ContentType.FacebookPost;
case Platform.LinkedIn:
return ContentType.LinkedInPost;
case Platform.LinkedInPage:
return ContentType.LinkedInPagePost;
case Platform.TikTok:
return ContentType.TikTokVideo;
case Platform.Pinterest:
return ContentType.PinterestPin;
case Platform.YouTube:
return ContentType.YouTubeShort;
case Platform.X:
return ContentType.XPost;
case Platform.Threads:
return ContentType.ThreadsPost;
case Platform.Bluesky:
return ContentType.BlueskyPost;
case Platform.Mastodon:
return ContentType.MastodonPost;
default:
return '';
}
};
const accountById = (id: string): SocialAccount | undefined =>
socialAccounts.value.find((a) => a.id === id);
const normalizeAccountsFromData = (): GenerateAccount[] => {
const incoming = props.data.accounts;
if (Array.isArray(incoming)) {
return (incoming as any[]).map((a) => ({
social_account_id: String(a.social_account_id ?? ''),
content_type: typeof a.content_type === 'string' && a.content_type
? a.content_type
: defaultContentTypeFor(accountById(String(a.social_account_id ?? ''))?.platform ?? ''),
meta: (a.meta as Record<string, any>) ?? {},
})).filter((a) => a.social_account_id);
}
// Backward-compat with the old shape (social_account_ids: string[]).
const legacyIds = props.data.social_account_ids;
if (Array.isArray(legacyIds)) {
return (legacyIds as string[]).map((id) => ({
social_account_id: id,
content_type: defaultContentTypeFor(accountById(id)?.platform ?? ''),
meta: {},
}));
}
return [];
};
const local = ref<GenerateConfig>({
accounts: normalizeAccountsFromData(),
target_slide_count: props.data.target_slide_count as number | undefined,
prompt_template: (props.data.prompt_template as string) ?? '',
image_source: (props.data.image_source as GenerateConfig['image_source']) ?? 'ai',
});
watch(local, (val) => emit('update', val), { deep: true });
const isSelected = (accountId: string): boolean =>
local.value.accounts.some((a) => a.social_account_id === accountId);
const toggleAccount = (account: SocialAccount) => {
if (isSelected(account.id)) {
local.value.accounts = local.value.accounts.filter((a) => a.social_account_id !== account.id);
return;
}
local.value.accounts = [
...local.value.accounts,
{
social_account_id: account.id,
content_type: defaultContentTypeFor(account.platform),
meta: {},
},
];
};
const selectedAccounts = computed(() =>
local.value.accounts
.map((entry) => {
const account = accountById(entry.social_account_id);
return account ? { entry, account } : null;
})
.filter((pair): pair is { entry: GenerateAccount; account: SocialAccount } => pair !== null),
);
const updateContentType = (accountId: string, value: string) => {
const idx = local.value.accounts.findIndex((a) => a.social_account_id === accountId);
if (idx === -1) return;
local.value.accounts[idx] = { ...local.value.accounts[idx], content_type: value };
};
const updateMeta = (accountId: string, value: Record<string, any>) => {
const idx = local.value.accounts.findIndex((a) => a.social_account_id === accountId);
if (idx === -1) return;
local.value.accounts[idx] = { ...local.value.accounts[idx], meta: value };
};
const getPublishConfig = (account: SocialAccount): Record<string, any> | null =>
platformConfigs.value[account.id]?.publishConfig ?? null;
const getCreatorInfo = (account: SocialAccount): TikTokCreatorInfo | null =>
tiktokCreatorInfos.value[account.id] ?? null;
const getBoards = (account: SocialAccount): PinterestBoard[] =>
pinterestBoards.value[account.id] ?? [];
// Carousel-capable content types: instagram_carousel, linkedin_carousel,
// linkedin_page_carousel, pinterest_carousel, tiktok_photo.
const carouselCapableContentTypes = new Set([
ContentType.InstagramCarousel,
ContentType.LinkedInCarousel,
ContentType.LinkedInPageCarousel,
ContentType.PinterestCarousel,
ContentType.TikTokPhoto,
]);
const hasCarouselCapableAccount = computed(() =>
local.value.accounts.some((a) => carouselCapableContentTypes.has(a.content_type as ContentType)),
);
</script>
<template>
<div class="space-y-4">
<div class="space-y-2">
<Label class="text-sm font-bold">{{ $t('automations.config.generate.social_accounts') }}</Label>
<InputError :message="errors?.accounts" />
<p v-if="socialAccounts.length === 0" class="text-xs text-foreground/60">
{{ $t('automations.config.generate.social_accounts_empty') }}
</p>
<div v-else class="space-y-2 px-1 pb-1">
<button
v-for="account in socialAccounts"
:key="account.id"
type="button"
class="relative flex w-full cursor-pointer items-center gap-2 rounded-xl border-2 border-foreground bg-card p-2.5 text-left text-sm shadow-2xs transition-all hover:bg-foreground/5"
:class="{ '!bg-violet-100 shadow-md': isSelected(account.id) }"
@click="toggleAccount(account)"
>
<span class="inline-flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card shadow-2xs">
<img
v-if="account.avatar_url"
:src="account.avatar_url"
:alt="account.display_name"
class="size-full object-cover"
/>
<span v-else class="text-xs font-bold text-foreground">{{ account.display_name.charAt(0).toUpperCase() }}</span>
</span>
<div class="min-w-0 flex-1">
<p class="truncate text-xs font-bold leading-tight text-foreground">{{ account.display_name }}</p>
<p class="truncate text-xs font-medium capitalize text-foreground/60">
{{ account.platform }}<span v-if="account.username"> · @{{ account.username }}</span>
</p>
</div>
<IconCheck
v-if="isSelected(account.id)"
class="absolute right-2 top-2 size-3.5 text-foreground"
stroke-width="3"
/>
</button>
</div>
</div>
<div v-if="selectedAccounts.length > 0" class="space-y-3">
<template v-for="{ entry, account } in selectedAccounts" :key="account.id">
<InstagramSettings
v-if="account.platform === Platform.Instagram || account.platform === Platform.InstagramFacebook"
:social-account="account"
:content-type="entry.content_type"
:media="[]"
:meta="entry.meta"
:preview-only="true"
@update:content-type="updateContentType(account.id, $event)"
@update:meta="updateMeta(account.id, $event)"
/>
<FacebookSettings
v-else-if="account.platform === Platform.Facebook"
:social-account="account"
:content-type="entry.content_type"
:media="[]"
:preview-only="true"
@update:content-type="updateContentType(account.id, $event)"
/>
<TikTokSettings
v-else-if="account.platform === Platform.TikTok"
:social-account="account"
:publish-config="getPublishConfig(account)"
:creator-info="getCreatorInfo(account)"
:video-duration-sec="null"
:content-type="entry.content_type"
:meta="entry.meta"
:preview-only="true"
@update:content-type="updateContentType(account.id, $event)"
@update:meta="updateMeta(account.id, $event)"
/>
<PinterestSettings
v-else-if="account.platform === Platform.Pinterest"
:social-account="account"
:content-type="entry.content_type"
:media="[]"
:boards="getBoards(account)"
:meta="entry.meta"
:preview-only="true"
@update:content-type="updateContentType(account.id, $event)"
@update:meta="updateMeta(account.id, $event)"
/>
<LinkedInSettings
v-else-if="account.platform === Platform.LinkedIn || account.platform === Platform.LinkedInPage"
:social-account="account"
:platform="account.platform"
:content-type="entry.content_type"
:media="[]"
:preview-only="true"
@update:content-type="updateContentType(account.id, $event)"
/>
</template>
</div>
<div v-if="hasCarouselCapableAccount">
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.generate.target_slide_count') }}</label>
<Input type="number" v-model.number="local.target_slide_count" placeholder="5" />
</div>
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.generate.prompt_template') }}</label>
<Textarea v-model="local.prompt_template" :rows="6" placeholder="Write a social media post about {{ trigger.title }}…" />
<InputError :message="errors?.prompt_template" class="mt-1" />
</div>
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.generate.image_source') }}</label>
<Select v-model="local.image_source">
<SelectTrigger class="w-full">
<SelectValue :placeholder="$t('automations.config.select_placeholder')" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ai">{{ $t('automations.config.generate.image_sources.ai') }}</SelectItem>
<SelectItem value="unsplash">{{ $t('automations.config.generate.image_sources.unsplash') }}</SelectItem>
<SelectItem value="none">{{ $t('automations.config.generate.image_sources.none') }}</SelectItem>
</SelectContent>
</Select>
<InputError :message="errors?.image_source" class="mt-1" />
</div>
</div>
</template>

View file

@ -0,0 +1,166 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import InputError from '@/components/InputError.vue';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
type AuthType = 'none' | 'bearer' | 'basic' | 'api_key';
interface HttpRequestConfig {
url: string;
method: Method;
auth_type: AuthType;
auth_token: string;
auth_username: string;
auth_password: string;
auth_header_name: string;
body_template: string;
items_path: string;
item_key_path: string;
item_date_path: string;
}
const props = defineProps<{
data: Record<string, unknown>;
errors?: Record<string, string>;
}>();
const emit = defineEmits<{ update: [Record<string, unknown>] }>();
const local = ref<HttpRequestConfig>({
url: (props.data.url as string) ?? '',
method: (props.data.method as Method) ?? 'GET',
auth_type: (props.data.auth_type as AuthType) ?? 'none',
auth_token: (props.data.auth_token as string) ?? '',
auth_username: (props.data.auth_username as string) ?? '',
auth_password: (props.data.auth_password as string) ?? '',
auth_header_name: (props.data.auth_header_name as string) ?? 'X-API-Key',
body_template: (props.data.body_template as string) ?? '',
items_path: (props.data.items_path as string) ?? '',
item_key_path: (props.data.item_key_path as string) ?? '',
item_date_path: (props.data.item_date_path as string) ?? '',
});
watch(local, (val) => emit('update', val), { deep: true });
const supportsBody = computed(() => ['POST', 'PUT', 'PATCH'].includes(local.value.method));
const isPollingMode = computed(() => local.value.items_path.trim() !== '');
</script>
<template>
<div class="space-y-4">
<div class="grid grid-cols-[110px_1fr] gap-2">
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.http_request.method') }}</label>
<Select v-model="local.method">
<SelectTrigger class="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="PATCH">PATCH</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
</SelectContent>
</Select>
<InputError :message="errors?.method" class="mt-1" />
</div>
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.http_request.url') }}</label>
<Input v-model="local.url" placeholder="https://api.example.com/items" />
<InputError :message="errors?.url" class="mt-1" />
</div>
</div>
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.http_request.auth_type') }}</label>
<Select v-model="local.auth_type">
<SelectTrigger class="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{{ $t('automations.config.http_request.auth.none') }}</SelectItem>
<SelectItem value="bearer">{{ $t('automations.config.http_request.auth.bearer') }}</SelectItem>
<SelectItem value="basic">{{ $t('automations.config.http_request.auth.basic') }}</SelectItem>
<SelectItem value="api_key">{{ $t('automations.config.http_request.auth.api_key') }}</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="local.auth_type === 'bearer'">
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.http_request.bearer_token') }}</label>
<Input v-model="local.auth_token" type="password" autocomplete="off" placeholder="sk-…" />
<InputError :message="errors?.auth_token" class="mt-1" />
</div>
<template v-if="local.auth_type === 'basic'">
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.http_request.basic_username') }}</label>
<Input v-model="local.auth_username" autocomplete="off" />
<InputError :message="errors?.auth_username" class="mt-1" />
</div>
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.http_request.basic_password') }}</label>
<Input v-model="local.auth_password" type="password" autocomplete="off" />
<InputError :message="errors?.auth_password" class="mt-1" />
</div>
</template>
<template v-if="local.auth_type === 'api_key'">
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.http_request.api_key_header') }}</label>
<Input v-model="local.auth_header_name" placeholder="X-API-Key" />
</div>
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.http_request.api_key_value') }}</label>
<Input v-model="local.auth_token" type="password" autocomplete="off" />
<InputError :message="errors?.auth_token" class="mt-1" />
</div>
</template>
<div v-if="supportsBody">
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.http_request.body_template') }}</label>
<Textarea v-model="local.body_template" :rows="5" placeholder='{"id": "{{ trigger.post.id }}"}' />
<InputError :message="errors?.body_template" class="mt-1" />
</div>
<div class="border-t-2 border-foreground/10 pt-4">
<p class="mb-2 text-[11px] font-black uppercase tracking-widest text-foreground/60">
{{ $t('automations.config.http_request.polling_section') }}
</p>
<p class="mb-3 text-xs text-foreground/60">
{{ $t('automations.config.http_request.polling_hint') }}
</p>
<div class="space-y-3">
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.http_request.items_path') }}</label>
<Input v-model="local.items_path" placeholder="data.items" />
<InputError :message="errors?.items_path" class="mt-1" />
</div>
<template v-if="isPollingMode">
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.http_request.item_key_path') }}</label>
<Input v-model="local.item_key_path" placeholder="id" />
<InputError :message="errors?.item_key_path" class="mt-1" />
</div>
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.http_request.item_date_path') }}</label>
<Input v-model="local.item_date_path" placeholder="published_at" />
<InputError :message="errors?.item_date_path" class="mt-1" />
<p class="mt-1 text-xs text-foreground/50">{{ $t('automations.config.http_request.item_date_path_hint') }}</p>
</div>
</template>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,56 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import InputError from '@/components/InputError.vue';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
interface PublishConfig {
mode: 'now' | 'scheduled' | 'draft';
scheduled_offset?: number;
}
const props = defineProps<{
data: Record<string, unknown>;
errors?: Record<string, string>;
}>();
const emit = defineEmits<{ update: [Record<string, unknown>] }>();
const local = ref<PublishConfig>({
mode: (props.data.mode as PublishConfig['mode']) ?? 'now',
scheduled_offset: (props.data.scheduled_offset as number) ?? 60,
});
watch(local, (val) => emit('update', val), { deep: true });
</script>
<template>
<div class="space-y-3">
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.publish.mode') }}</label>
<Select v-model="local.mode">
<SelectTrigger class="w-full">
<SelectValue :placeholder="$t('automations.config.select_placeholder')" />
</SelectTrigger>
<SelectContent>
<SelectItem value="now">{{ $t('automations.config.publish.modes.now') }}</SelectItem>
<SelectItem value="scheduled">{{ $t('automations.config.publish.modes.scheduled') }}</SelectItem>
<SelectItem value="draft">{{ $t('automations.config.publish.modes.draft') }}</SelectItem>
</SelectContent>
</Select>
<InputError :message="errors?.mode" class="mt-1" />
</div>
<div v-if="local.mode === 'scheduled'">
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.publish.scheduled_offset') }}</label>
<Input type="number" v-model.number="local.scheduled_offset" placeholder="60" />
<InputError :message="errors?.scheduled_offset" class="mt-1" />
</div>
</div>
</template>

View file

@ -0,0 +1,282 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import InputError from '@/components/InputError.vue';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
generateScheduleCron,
humanSchedule as scheduleSummary,
normalizeScheduleData,
timezoneAbbr as getTimezoneAbbr,
userTimezone as getUserTimezone,
type ScheduleData,
} from '@/components/automations/schedule-summary';
import { ScheduleField } from '@/types/automation/schedule-field';
import { TriggerType, type TriggerTypeValue } from '@/types/automation/trigger-type';
const props = defineProps<{
data: Record<string, unknown>;
errors?: Record<string, string>;
}>();
const emit = defineEmits<{ update: [Record<string, unknown>] }>();
const pad2 = (n: number) => String(n).padStart(2, '0');
const clamp = (n: number, min: number, max: number) => Math.min(Math.max(n, min), max);
const snap5 = (n: number) => (Math.round(n / 5) * 5) % 60;
const timezoneAbbr = computed(() => getTimezoneAbbr());
// Single source of truth for default + inferred field values, shared with the
// Trigger card via `triggerSummary`. Anything beyond `normalizeScheduleData`'s
// scope (trigger_type, cron, timezone) is filled in here.
const local = ref<ScheduleData & { trigger_type: TriggerTypeValue; cron: string; schedule_timezone: string }>({
...normalizeScheduleData(props.data as ScheduleData),
trigger_type: (props.data.trigger_type as TriggerTypeValue) ?? TriggerType.Schedule,
cron: (props.data.cron as string) ?? '0 9 * * *',
schedule_minute: snap5(Number(props.data.schedule_minute ?? normalizeScheduleData(props.data as ScheduleData).schedule_minute) || 0),
schedule_timezone: (props.data.schedule_timezone as string) ?? getUserTimezone(),
});
const num = (key: keyof typeof local.value, fallback: number, min: number, max: number) =>
clamp(Number(local.value[key]) || fallback, min, max);
const proxy = (key: 'schedule_hour' | 'schedule_minute', max: number) => computed({
get: () => pad2(num(key, 0, 0, max)),
set: (v: string) => { local.value[key] = Number(v) || 0; },
});
const scheduleHourStr = proxy('schedule_hour', 23);
const scheduleMinuteStr = proxy('schedule_minute', 59);
const hourOptions = Array.from({ length: 24 }, (_, i) => pad2(i));
const minuteOptions = Array.from({ length: 12 }, (_, i) => pad2(i * 5));
const weekdays = [
{ value: 1, label: 'mon' },
{ value: 2, label: 'tue' },
{ value: 3, label: 'wed' },
{ value: 4, label: 'thu' },
{ value: 5, label: 'fri' },
{ value: 6, label: 'sat' },
{ value: 0, label: 'sun' },
] as const;
const toggleWeekday = (value: number) => {
const set = new Set(local.value.schedule_weekdays ?? []);
set.has(value) ? set.delete(value) : set.add(value);
local.value.schedule_weekdays = Array.from(set);
};
const isWeekdaySelected = (value: number) =>
(local.value.schedule_weekdays ?? []).includes(value);
const generatedCron = computed(() => generateScheduleCron(local.value));
const humanSchedule = computed(() => scheduleSummary(local.value));
watch(generatedCron, (cron) => {
if (local.value.trigger_type === TriggerType.Schedule) {
local.value.cron = cron;
local.value.schedule_timezone = getUserTimezone();
}
}, { immediate: true });
watch(local, (val) => emit('update', val), { deep: true });
</script>
<template>
<div class="space-y-3">
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.trigger.type') }}</label>
<Select v-model="local.trigger_type">
<SelectTrigger class="w-full">
<SelectValue :placeholder="$t('automations.config.select_placeholder')" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="TriggerType.Schedule">{{ $t('automations.config.trigger.types.schedule') }}</SelectItem>
<SelectItem :value="TriggerType.PostPublished">{{ $t('automations.config.trigger.types.post_published') }}</SelectItem>
<SelectItem :value="TriggerType.PostScheduled">{{ $t('automations.config.trigger.types.post_scheduled') }}</SelectItem>
</SelectContent>
</Select>
<InputError :message="errors?.trigger_type" class="mt-1" />
</div>
<template v-if="local.trigger_type === TriggerType.PostPublished">
<p class="rounded-md bg-muted px-3 py-2 text-xs text-foreground/70">
{{ $t('automations.config.trigger.post_published_hint') }}
</p>
</template>
<template v-if="local.trigger_type === TriggerType.PostScheduled">
<p class="rounded-md bg-muted px-3 py-2 text-xs text-foreground/70">
{{ $t('automations.config.trigger.post_scheduled_hint') }}
</p>
</template>
<template v-if="local.trigger_type === TriggerType.Schedule">
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.trigger.schedule.field') }}</label>
<Select v-model="local.schedule_field">
<SelectTrigger class="w-full">
<SelectValue :placeholder="$t('automations.config.select_placeholder')" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="ScheduleField.Minutes">{{ $t('automations.config.trigger.schedule.fields.minutes') }}</SelectItem>
<SelectItem :value="ScheduleField.Hours">{{ $t('automations.config.trigger.schedule.fields.hours') }}</SelectItem>
<SelectItem :value="ScheduleField.Days">{{ $t('automations.config.trigger.schedule.fields.days') }}</SelectItem>
<SelectItem :value="ScheduleField.Weeks">{{ $t('automations.config.trigger.schedule.fields.weeks') }}</SelectItem>
<SelectItem :value="ScheduleField.Months">{{ $t('automations.config.trigger.schedule.fields.months') }}</SelectItem>
<SelectItem :value="ScheduleField.Custom">{{ $t('automations.config.trigger.schedule.fields.custom') }}</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="local.schedule_field === ScheduleField.Minutes">
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.trigger.schedule.minutes_interval') }}</label>
<Input type="number" v-model.number="local.schedule_minutes_interval" min="1" max="59" />
</div>
<template v-if="local.schedule_field === ScheduleField.Hours">
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.trigger.schedule.hours_interval') }}</label>
<Input type="number" v-model.number="local.schedule_hours_interval" min="1" max="23" />
</div>
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.trigger.schedule.minute') }}</label>
<Select v-model="scheduleMinuteStr">
<SelectTrigger class="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="m in minuteOptions" :key="m" :value="m">{{ m }}</SelectItem>
</SelectContent>
</Select>
</div>
</template>
<template v-if="local.schedule_field === ScheduleField.Days">
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.trigger.schedule.days_interval') }}</label>
<Input type="number" v-model.number="local.schedule_days_interval" min="1" max="31" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.trigger.schedule.hour') }}</label>
<Select v-model="scheduleHourStr">
<SelectTrigger class="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="h in hourOptions" :key="h" :value="h">{{ h }}</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.trigger.schedule.minute') }}</label>
<Select v-model="scheduleMinuteStr">
<SelectTrigger class="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="m in minuteOptions" :key="m" :value="m">{{ m }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</template>
<template v-if="local.schedule_field === ScheduleField.Weeks">
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.trigger.schedule.weekdays') }}</label>
<div class="flex flex-wrap gap-1.5">
<button
v-for="day in weekdays"
:key="day.value"
type="button"
class="rounded-md border-2 px-3 py-1 text-xs font-semibold transition-colors"
:class="isWeekdaySelected(day.value)
? 'border-foreground bg-amber-200 text-foreground'
: 'border-foreground/15 bg-card text-foreground/70 hover:border-foreground/30'"
@click="toggleWeekday(day.value)"
>
{{ $t(`automations.config.trigger.schedule.weekday_names.${day.label}`) }}
</button>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.trigger.schedule.hour') }}</label>
<Select v-model="scheduleHourStr">
<SelectTrigger class="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="h in hourOptions" :key="h" :value="h">{{ h }}</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.trigger.schedule.minute') }}</label>
<Select v-model="scheduleMinuteStr">
<SelectTrigger class="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="m in minuteOptions" :key="m" :value="m">{{ m }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</template>
<template v-if="local.schedule_field === ScheduleField.Months">
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.trigger.schedule.day_of_month') }}</label>
<Input type="number" v-model.number="local.schedule_day_of_month" min="1" max="31" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.trigger.schedule.hour') }}</label>
<Select v-model="scheduleHourStr">
<SelectTrigger class="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="h in hourOptions" :key="h" :value="h">{{ h }}</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.trigger.schedule.minute') }}</label>
<Select v-model="scheduleMinuteStr">
<SelectTrigger class="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="m in minuteOptions" :key="m" :value="m">{{ m }}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</template>
<template v-if="local.schedule_field === ScheduleField.Custom">
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.trigger.schedule.custom_cron') }}</label>
<Input v-model="local.schedule_custom_cron" placeholder="0 9 * * 1,3,5" />
<InputError :message="errors?.cron" class="mt-1" />
<p class="mt-1 text-xs text-foreground/50">{{ $t('automations.config.trigger.schedule.custom_cron_hint') }}</p>
</div>
</template>
<p class="rounded-md bg-muted px-3 py-2 text-xs text-foreground/70">{{ humanSchedule }}</p>
<p v-if="local.schedule_field !== ScheduleField.Custom" class="text-xs text-foreground/50">{{ $t('automations.config.trigger.schedule.timezone_hint', { tz: timezoneAbbr }) }}</p>
</template>
</div>
</template>

View file

@ -0,0 +1,69 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import InputError from '@/components/InputError.vue';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
interface WebhookConfig {
url: string;
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
headers?: Record<string, string>;
payload_template: string;
}
const props = defineProps<{
data: Record<string, unknown>;
errors?: Record<string, string>;
}>();
const emit = defineEmits<{ update: [Record<string, unknown>] }>();
const local = ref<WebhookConfig>({
url: (props.data.url as string) ?? '',
method: (props.data.method as WebhookConfig['method']) ?? 'POST',
headers: (props.data.headers as Record<string, string>) ?? {},
payload_template: (props.data.payload_template as string) ?? '{}',
});
watch(local, (val) => emit('update', val), { deep: true });
</script>
<template>
<div class="space-y-3">
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.webhook.url') }}</label>
<Input v-model="local.url" placeholder="https://hooks.example.com/…" />
<InputError :message="errors?.url" class="mt-1" />
</div>
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.webhook.method') }}</label>
<Select v-model="local.method">
<SelectTrigger class="w-full">
<SelectValue :placeholder="$t('automations.config.select_placeholder')" />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="PATCH">PATCH</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
</SelectContent>
</Select>
<InputError :message="errors?.method" class="mt-1" />
</div>
<div>
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.webhook.payload_template') }}</label>
<Textarea v-model="local.payload_template" :rows="6" placeholder='{"content": "{{ post.content }}"}' />
<InputError :message="errors?.payload_template" class="mt-1" />
</div>
</div>
</template>

View file

@ -0,0 +1,60 @@
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core';
import { IconGitBranch } from '@tabler/icons-vue';
import { computed } from 'vue';
const props = defineProps<{
data: {
field?: string;
operator?: string;
value?: string;
};
selected?: boolean;
}>();
const summary = computed(() => {
const field = props.data.field || '…';
const operator = props.data.operator || 'contains';
const value = props.data.value || '…';
return `${field} ${operator} ${value}`;
});
</script>
<template>
<div
class="automation-node automation-node--wide automation-node--accent-rose"
:class="{ 'is-selected': selected }"
>
<div class="automation-node__header">
<div class="automation-node__icon-tile automation-node__icon-tile--rose">
<IconGitBranch :size="16" />
</div>
<span class="automation-node__title">{{ $t('automations.nodes.condition') }}</span>
</div>
<div class="automation-node__summary" :title="summary">
{{ summary }}
</div>
<Handle
type="target"
:position="Position.Left"
class="!bg-rose-500"
/>
<Handle
id="yes"
type="source"
:position="Position.Right"
class="!bg-emerald-500"
:style="{ top: '35%' }"
/>
<span class="pointer-events-none absolute left-full top-[35%] z-10 ml-3 -translate-y-1/2 rounded bg-background px-1.5 text-[10px] font-bold uppercase tracking-wider text-emerald-700">yes</span>
<Handle
id="no"
type="source"
:position="Position.Right"
class="!bg-rose-500"
:style="{ top: '75%' }"
/>
<span class="pointer-events-none absolute left-full top-[75%] z-10 ml-3 -translate-y-1/2 rounded bg-background px-1.5 text-[10px] font-bold uppercase tracking-wider text-rose-700">no</span>
</div>
</template>

View file

@ -0,0 +1,46 @@
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core';
import { IconClock } from '@tabler/icons-vue';
import { computed } from 'vue';
const props = defineProps<{
data: {
duration?: number;
unit?: string;
};
selected?: boolean;
}>();
const summary = computed(() => {
const duration = props.data.duration ?? 1;
const unit = props.data.unit ?? 'hours';
return `${duration} ${unit}`;
});
</script>
<template>
<div
class="automation-node automation-node--accent-amber"
:class="{ 'is-selected': selected }"
>
<div class="automation-node__header">
<div class="automation-node__icon-tile automation-node__icon-tile--amber">
<IconClock :size="16" />
</div>
<span class="automation-node__title">{{ $t('automations.nodes.delay') }}</span>
</div>
<div class="automation-node__summary">
{{ summary }}
</div>
<Handle
type="target"
:position="Position.Left"
class="!bg-amber-500"
/>
<Handle
type="source"
:position="Position.Right"
class="!bg-amber-500"
/>
</div>
</template>

View file

@ -0,0 +1,36 @@
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core';
import { IconCircleX } from '@tabler/icons-vue';
import { computed } from 'vue';
const props = defineProps<{
data: {
reason?: string;
};
selected?: boolean;
}>();
const summary = computed(() => props.data.reason || null);
</script>
<template>
<div
class="automation-node automation-node--accent-zinc"
:class="{ 'is-selected': selected }"
>
<div class="automation-node__header">
<div class="automation-node__icon-tile automation-node__icon-tile--zinc">
<IconCircleX :size="16" />
</div>
<span class="automation-node__title">{{ $t('automations.nodes.end') }}</span>
</div>
<div class="automation-node__summary">
{{ summary ?? $t('automations.nodes.end_summary') }}
</div>
<Handle
type="target"
:position="Position.Left"
class="!bg-zinc-500"
/>
</div>
</template>

View file

@ -0,0 +1,41 @@
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core';
import { IconRss } from '@tabler/icons-vue';
import { computed } from 'vue';
const props = defineProps<{
data: {
feed_url?: string;
};
selected?: boolean;
}>();
const summary = computed(() => {
const url = props.data.feed_url;
if (!url) return '—';
try {
return new URL(url).hostname;
} catch {
return url;
}
});
</script>
<template>
<div
class="automation-node automation-node--accent-amber"
:class="{ 'is-selected': selected }"
>
<div class="automation-node__header">
<div class="automation-node__icon-tile automation-node__icon-tile--amber">
<IconRss :size="16" />
</div>
<span class="automation-node__title">{{ $t('automations.nodes.fetch_rss') }}</span>
</div>
<div class="automation-node__summary">
{{ summary }}
</div>
<Handle type="target" :position="Position.Left" class="!bg-amber-500" />
<Handle type="source" :position="Position.Right" class="!bg-amber-500" />
</div>
</template>

View file

@ -0,0 +1,48 @@
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core';
import { IconSparkles } from '@tabler/icons-vue';
import { computed } from 'vue';
const props = defineProps<{
data: {
accounts?: Array<{ social_account_id: string }>;
social_account_ids?: string[];
format?: string;
};
selected?: boolean;
}>();
const summary = computed(() => {
const count = props.data.accounts?.length ?? props.data.social_account_ids?.length ?? 0;
const format = props.data.format ?? 'single';
const accountLabel = count === 1 ? 'account' : 'accounts';
return `${count} ${accountLabel} · ${format}`;
});
</script>
<template>
<div
class="automation-node automation-node--accent-blue"
:class="{ 'is-selected': selected }"
>
<div class="automation-node__header">
<div class="automation-node__icon-tile automation-node__icon-tile--blue">
<IconSparkles :size="16" />
</div>
<span class="automation-node__title">{{ $t('automations.nodes.generate') }}</span>
</div>
<div class="automation-node__summary">
{{ summary }}
</div>
<Handle
type="target"
:position="Position.Left"
class="!bg-blue-500"
/>
<Handle
type="source"
:position="Position.Right"
class="!bg-blue-500"
/>
</div>
</template>

View file

@ -0,0 +1,45 @@
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core';
import { IconWorld } from '@tabler/icons-vue';
import { computed } from 'vue';
const props = defineProps<{
data: {
url?: string;
method?: string;
};
selected?: boolean;
}>();
const summary = computed(() => {
const method = (props.data.method ?? 'GET').toUpperCase();
const url = props.data.url;
if (!url) return method;
let host = url;
try {
host = new URL(url).hostname;
} catch {
// not a valid URL, fall back to raw string
}
return `${method} · ${host}`;
});
</script>
<template>
<div
class="automation-node automation-node--accent-slate"
:class="{ 'is-selected': selected }"
>
<div class="automation-node__header">
<div class="automation-node__icon-tile automation-node__icon-tile--slate">
<IconWorld :size="16" />
</div>
<span class="automation-node__title">{{ $t('automations.nodes.http_request') }}</span>
</div>
<div class="automation-node__summary">
{{ summary }}
</div>
<Handle type="target" :position="Position.Left" class="!bg-slate-500" />
<Handle type="source" :position="Position.Right" class="!bg-slate-500" />
</div>
</template>

View file

@ -0,0 +1,48 @@
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core';
import { IconSend } from '@tabler/icons-vue';
import { computed } from 'vue';
const props = defineProps<{
data: {
mode?: string;
scheduled_offset?: number;
};
selected?: boolean;
}>();
const summary = computed(() => {
const mode = props.data.mode ?? 'now';
if (mode === 'scheduled' && props.data.scheduled_offset != null) {
return `scheduled · +${props.data.scheduled_offset} min`;
}
return mode;
});
</script>
<template>
<div
class="automation-node automation-node--accent-emerald"
:class="{ 'is-selected': selected }"
>
<div class="automation-node__header">
<div class="automation-node__icon-tile automation-node__icon-tile--emerald">
<IconSend :size="16" />
</div>
<span class="automation-node__title">{{ $t('automations.nodes.publish') }}</span>
</div>
<div class="automation-node__summary">
{{ summary }}
</div>
<Handle
type="target"
:position="Position.Left"
class="!bg-emerald-500"
/>
<Handle
type="source"
:position="Position.Right"
class="!bg-emerald-500"
/>
</div>
</template>

View file

@ -0,0 +1,37 @@
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core';
import { IconBolt } from '@tabler/icons-vue';
import { computed } from 'vue';
import { triggerSummary } from '@/components/automations/schedule-summary';
import type { ScheduleData } from '@/types/automation/schedule-data';
const props = defineProps<{
data: ScheduleData;
selected?: boolean;
}>();
const summary = computed(() => triggerSummary(props.data));
</script>
<template>
<div
class="automation-node automation-node--accent-violet"
:class="{ 'is-selected': selected }"
>
<div class="automation-node__header">
<div class="automation-node__icon-tile automation-node__icon-tile--violet">
<IconBolt :size="16" />
</div>
<span class="automation-node__title">{{ $t('automations.nodes.trigger') }}</span>
</div>
<div class="automation-node__summary">
{{ summary }}
</div>
<Handle
type="source"
:position="Position.Right"
class="!bg-violet-500"
/>
</div>
</template>

View file

@ -0,0 +1,46 @@
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core';
import { IconWebhook } from '@tabler/icons-vue';
import { computed } from 'vue';
const props = defineProps<{
data: {
url?: string;
method?: string;
};
selected?: boolean;
}>();
const summary = computed(() => {
const method = (props.data.method ?? 'POST').toUpperCase();
const url = props.data.url || 'https://…';
return `${method} · ${url}`;
});
</script>
<template>
<div
class="automation-node automation-node--wide automation-node--accent-slate"
:class="{ 'is-selected': selected }"
>
<div class="automation-node__header">
<div class="automation-node__icon-tile automation-node__icon-tile--slate">
<IconWebhook :size="16" />
</div>
<span class="automation-node__title">{{ $t('automations.nodes.webhook') }}</span>
</div>
<div class="automation-node__summary" :title="summary">
{{ summary }}
</div>
<Handle
type="target"
:position="Position.Left"
class="!bg-slate-500"
/>
<Handle
type="source"
:position="Position.Right"
class="!bg-slate-500"
/>
</div>
</template>

Some files were not shown because too many files have changed in this diff Show more