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:
parent
fc18f044af
commit
b23ab0166e
160 changed files with 9562 additions and 27 deletions
41
app/Actions/Automation/Automation/ActivateAutomation.php
Normal file
41
app/Actions/Automation/Automation/ActivateAutomation.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
}
|
||||
25
app/Actions/Automation/Automation/CreateAutomation.php
Normal file
25
app/Actions/Automation/Automation/CreateAutomation.php
Normal 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' => [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
21
app/Actions/Automation/Automation/PauseAutomation.php
Normal file
21
app/Actions/Automation/Automation/PauseAutomation.php
Normal 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;
|
||||
}
|
||||
}
|
||||
65
app/Actions/Automation/Automation/UpdateAutomation.php
Normal file
65
app/Actions/Automation/Automation/UpdateAutomation.php
Normal 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;
|
||||
}
|
||||
}
|
||||
61
app/Actions/Automation/Node/RunConditionNode.php
Normal file
61
app/Actions/Automation/Node/RunConditionNode.php
Normal 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;
|
||||
}
|
||||
}
|
||||
26
app/Actions/Automation/Node/RunDelayNode.php
Normal file
26
app/Actions/Automation/Node/RunDelayNode.php
Normal 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);
|
||||
}
|
||||
}
|
||||
23
app/Actions/Automation/Node/RunEndNode.php
Normal file
23
app/Actions/Automation/Node/RunEndNode.php
Normal 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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
200
app/Actions/Automation/Node/RunFetchRssNode.php
Normal file
200
app/Actions/Automation/Node/RunFetchRssNode.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
283
app/Actions/Automation/Node/RunGenerateNode.php
Normal file
283
app/Actions/Automation/Node/RunGenerateNode.php
Normal 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));
|
||||
}
|
||||
}
|
||||
299
app/Actions/Automation/Node/RunHttpRequestNode.php
Normal file
299
app/Actions/Automation/Node/RunHttpRequestNode.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
59
app/Actions/Automation/Node/RunPublishNode.php
Normal file
59
app/Actions/Automation/Node/RunPublishNode.php
Normal 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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
46
app/Actions/Automation/Node/RunWebhookNode.php
Normal file
46
app/Actions/Automation/Node/RunWebhookNode.php
Normal 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),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
32
app/Actions/Automation/Run/AdvanceAutomationRun.php
Normal file
32
app/Actions/Automation/Run/AdvanceAutomationRun.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
54
app/Actions/Automation/Run/DispatchAutomationRun.php
Normal file
54
app/Actions/Automation/Run/DispatchAutomationRun.php
Normal 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;
|
||||
}
|
||||
}
|
||||
27
app/Actions/Automation/Run/RetryRunFromNode.php
Normal file
27
app/Actions/Automation/Run/RetryRunFromNode.php
Normal 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);
|
||||
}
|
||||
}
|
||||
121
app/Actions/Automation/Run/TestAutomation.php
Normal file
121
app/Actions/Automation/Run/TestAutomation.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
36
app/Actions/Automation/Trigger/FireScheduleTrigger.php
Normal file
36
app/Actions/Automation/Trigger/FireScheduleTrigger.php
Normal 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;
|
||||
}
|
||||
}
|
||||
35
app/Actions/Automation/TriggerItem/EnrollTriggerItem.php
Normal file
35
app/Actions/Automation/TriggerItem/EnrollTriggerItem.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
16
app/Broadcasting/AutomationChannel.php
Normal file
16
app/Broadcasting/AutomationChannel.php
Normal 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);
|
||||
}
|
||||
}
|
||||
34
app/Console/Commands/Automation/FireScheduleTriggers.php
Normal file
34
app/Console/Commands/Automation/FireScheduleTriggers.php
Normal 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;
|
||||
}
|
||||
}
|
||||
39
app/Console/Commands/Automation/ProcessAutomationDelays.php
Normal file
39
app/Console/Commands/Automation/ProcessAutomationDelays.php
Normal 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;
|
||||
}
|
||||
}
|
||||
34
app/DataTransferObjects/Automation/NodeRunResult.php
Normal file
34
app/DataTransferObjects/Automation/NodeRunResult.php
Normal 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 ?? []));
|
||||
}
|
||||
}
|
||||
16
app/Enums/Automation/Condition/Operator.php
Normal file
16
app/Enums/Automation/Condition/Operator.php
Normal 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';
|
||||
}
|
||||
18
app/Enums/Automation/Node/Type.php
Normal file
18
app/Enums/Automation/Node/Type.php
Normal 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';
|
||||
}
|
||||
13
app/Enums/Automation/NodeRun/Status.php
Normal file
13
app/Enums/Automation/NodeRun/Status.php
Normal 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';
|
||||
}
|
||||
12
app/Enums/Automation/Publish/Mode.php
Normal file
12
app/Enums/Automation/Publish/Mode.php
Normal 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';
|
||||
}
|
||||
15
app/Enums/Automation/Run/Status.php
Normal file
15
app/Enums/Automation/Run/Status.php
Normal 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';
|
||||
}
|
||||
12
app/Enums/Automation/Status.php
Normal file
12
app/Enums/Automation/Status.php
Normal 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';
|
||||
}
|
||||
12
app/Enums/Automation/Trigger/Type.php
Normal file
12
app/Enums/Automation/Trigger/Type.php
Normal 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';
|
||||
}
|
||||
53
app/Events/AutomationRunUpdated.php
Normal file
53
app/Events/AutomationRunUpdated.php
Normal 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';
|
||||
}
|
||||
}
|
||||
196
app/Http/Controllers/App/AutomationController.php
Normal file
196
app/Http/Controllers/App/AutomationController.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
23
app/Http/Requests/App/Automations/PauseAutomationRequest.php
Normal file
23
app/Http/Requests/App/Automations/PauseAutomationRequest.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
25
app/Http/Requests/App/Automations/RetryRunRequest.php
Normal file
25
app/Http/Requests/App/Automations/RetryRunRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
25
app/Http/Requests/App/Automations/StoreAutomationRequest.php
Normal file
25
app/Http/Requests/App/Automations/StoreAutomationRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
25
app/Http/Requests/App/Automations/TestAutomationRequest.php
Normal file
25
app/Http/Requests/App/Automations/TestAutomationRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
141
app/Http/Requests/App/Automations/UpdateAutomationRequest.php
Normal file
141
app/Http/Requests/App/Automations/UpdateAutomationRequest.php
Normal 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 => [],
|
||||
};
|
||||
}
|
||||
}
|
||||
29
app/Http/Resources/AutomationNodeRunResource.php
Normal file
29
app/Http/Resources/AutomationNodeRunResource.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
53
app/Http/Resources/AutomationResource.php
Normal file
53
app/Http/Resources/AutomationResource.php
Normal 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;
|
||||
}
|
||||
}
|
||||
33
app/Http/Resources/AutomationRunResource.php
Normal file
33
app/Http/Resources/AutomationRunResource.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
25
app/Http/Resources/AutomationTriggerItemResource.php
Normal file
25
app/Http/Resources/AutomationTriggerItemResource.php
Normal 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')),
|
||||
];
|
||||
}
|
||||
}
|
||||
135
app/Jobs/Automation/ProcessAutomationNode.php
Normal file
135
app/Jobs/Automation/ProcessAutomationNode.php
Normal 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
125
app/Models/Automation.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
33
app/Models/AutomationNodeRun.php
Normal file
33
app/Models/AutomationNodeRun.php
Normal 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');
|
||||
}
|
||||
}
|
||||
38
app/Models/AutomationNodeState.php
Normal file
38
app/Models/AutomationNodeState.php
Normal 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' => []],
|
||||
);
|
||||
}
|
||||
}
|
||||
59
app/Models/AutomationRun.php
Normal file
59
app/Models/AutomationRun.php
Normal 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);
|
||||
}
|
||||
}
|
||||
32
app/Models/AutomationTriggerItem.php
Normal file
32
app/Models/AutomationTriggerItem.php
Normal 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');
|
||||
}
|
||||
}
|
||||
23
app/Observers/AutomationNodeRunObserver.php
Normal file
23
app/Observers/AutomationNodeRunObserver.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
25
app/Observers/AutomationRunObserver.php
Normal file
25
app/Observers/AutomationRunObserver.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
34
app/Observers/PostObserver.php
Normal file
34
app/Observers/PostObserver.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
46
app/Policies/AutomationPolicy.php
Normal file
46
app/Policies/AutomationPolicy.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
42
app/Services/Automation/ExpressionResolver.php
Normal file
42
app/Services/Automation/ExpressionResolver.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
|
|
|||
60
database/factories/AutomationFactory.php
Normal file
60
database/factories/AutomationFactory.php
Normal 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' => [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
26
database/factories/AutomationNodeRunFactory.php
Normal file
26
database/factories/AutomationNodeRunFactory.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
49
database/factories/AutomationRunFactory.php
Normal file
49
database/factories/AutomationRunFactory.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
25
database/factories/AutomationTriggerItemFactory.php
Normal file
25
database/factories/AutomationTriggerItemFactory.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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
248
lang/en/automations.php
Normal 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.',
|
||||
],
|
||||
];
|
||||
|
|
@ -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',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
],
|
||||
|
||||
'analytics' => 'Analytics',
|
||||
'automations' => 'Automations',
|
||||
'settings' => 'Settings',
|
||||
|
||||
'posts' => [
|
||||
|
|
|
|||
248
lang/es/automations.php
Normal file
248
lang/es/automations.php
Normal 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.',
|
||||
],
|
||||
];
|
||||
|
|
@ -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',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
],
|
||||
|
||||
'analytics' => 'Analytics',
|
||||
'automations' => 'Automatizaciones',
|
||||
'settings' => 'Configuración',
|
||||
|
||||
'posts' => [
|
||||
|
|
|
|||
248
lang/pt-BR/automations.php
Normal file
248
lang/pt-BR/automations.php
Normal 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.',
|
||||
],
|
||||
];
|
||||
|
|
@ -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',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
],
|
||||
|
||||
'analytics' => 'Analytics',
|
||||
'automations' => 'Automações',
|
||||
'settings' => 'Configurações',
|
||||
|
||||
'posts' => [
|
||||
|
|
|
|||
168
package-lock.json
generated
168
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
||||
|
|
|
|||
|
|
@ -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[]>(() => [
|
||||
|
|
|
|||
51
resources/js/components/JsonViewer.vue
Normal file
51
resources/js/components/JsonViewer.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
225
resources/js/components/automations/TestRunPanel.vue
Normal file
225
resources/js/components/automations/TestRunPanel.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
33
resources/js/components/automations/config/EndNodeConfig.vue
Normal file
33
resources/js/components/automations/config/EndNodeConfig.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
282
resources/js/components/automations/config/TriggerNodeConfig.vue
Normal file
282
resources/js/components/automations/config/TriggerNodeConfig.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
60
resources/js/components/automations/nodes/ConditionNode.vue
Normal file
60
resources/js/components/automations/nodes/ConditionNode.vue
Normal 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>
|
||||
46
resources/js/components/automations/nodes/DelayNode.vue
Normal file
46
resources/js/components/automations/nodes/DelayNode.vue
Normal 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>
|
||||
36
resources/js/components/automations/nodes/EndNode.vue
Normal file
36
resources/js/components/automations/nodes/EndNode.vue
Normal 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>
|
||||
41
resources/js/components/automations/nodes/FetchRssNode.vue
Normal file
41
resources/js/components/automations/nodes/FetchRssNode.vue
Normal 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>
|
||||
48
resources/js/components/automations/nodes/GenerateNode.vue
Normal file
48
resources/js/components/automations/nodes/GenerateNode.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
48
resources/js/components/automations/nodes/PublishNode.vue
Normal file
48
resources/js/components/automations/nodes/PublishNode.vue
Normal 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>
|
||||
37
resources/js/components/automations/nodes/TriggerNode.vue
Normal file
37
resources/js/components/automations/nodes/TriggerNode.vue
Normal 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>
|
||||
46
resources/js/components/automations/nodes/WebhookNode.vue
Normal file
46
resources/js/components/automations/nodes/WebhookNode.vue
Normal 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
Loading…
Reference in a new issue