Enhance automation functionality: Introduce workflow variables and improve node validation

- Added support for workflow variables in automations, allowing users to define reusable values.
- Implemented validation for Generate nodes to ensure intended image counts align with selected accounts.
- Updated automation models and requests to handle new variables, including encryption for sensitive data.
- Enhanced UI to display variables and their management within the automation editor.
- Improved error handling for webhook and HTTP nodes to prevent requests to invalid URLs.
- Refactored various components for better context resolution during automation runs.
This commit is contained in:
Paulo Castellano 2026-06-11 15:47:29 -03:00
parent 4efaa0bf99
commit 9a692b4608
49 changed files with 1723 additions and 655 deletions

View file

@ -4,11 +4,15 @@
namespace App\Actions\Automation\Automation;
use App\Enums\Automation\Node\Type as NodeType;
use App\Enums\Automation\Status;
use App\Models\Automation;
use App\Services\Automation\GenerateNodeValidator;
class ActivateAutomation
{
public function __construct(private GenerateNodeValidator $generateValidator) {}
public function __invoke(Automation $automation): Automation
{
$this->validate($automation);
@ -37,5 +41,17 @@ private function validate(Automation $automation): void
if (! $hasTargetFromTrigger) {
throw new \DomainException(__('automations.errors.trigger_must_be_connected'));
}
foreach ($nodes as $node) {
if (data_get($node, 'type') !== NodeType::Generate->value) {
continue;
}
$issue = $this->generateValidator->issueFor((array) data_get($node, 'data', []));
if ($issue !== null) {
throw new \DomainException($issue);
}
}
}
}

View file

@ -5,9 +5,11 @@
namespace App\Actions\Automation\Automation;
use App\Enums\Automation\Status;
use App\Enums\Automation\Trigger\Type as TriggerType;
use App\Models\Automation;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Str;
class CreateAutomation
{
@ -18,8 +20,33 @@ public function __invoke(Workspace $workspace, User $user, ?string $name = null)
'user_id' => $user->id,
'name' => $name ?: __('automations.default_name'),
'status' => Status::Draft,
'nodes' => [],
'nodes' => [$this->defaultTriggerNode()],
'connections' => [],
]);
}
/**
* Every automation has exactly one trigger — its entry point — so we seed it
* on creation. The trigger can't be added or deleted from the editor; only
* its type (schedule / post published / post scheduled) is configurable.
*
* @return array<string, mixed>
*/
private function defaultTriggerNode(): array
{
return [
'id' => (string) Str::uuid(),
'type' => 'trigger',
'position' => ['x' => 0, 'y' => 0],
'data' => [
'trigger_type' => TriggerType::Schedule->value,
'cron' => '0 9 * * *',
'schedule_field' => 'days',
'schedule_days_interval' => 1,
'schedule_hour' => 9,
'schedule_minute' => 0,
'schedule_timezone' => config('app.timezone'),
],
];
}
}

View file

@ -17,6 +17,7 @@ public function __invoke(Automation $automation, array $data): Automation
'name' => $data['name'] ?? $automation->name,
'nodes' => $data['nodes'] ?? $automation->nodes,
'connections' => $data['connections'] ?? $automation->connections,
'variables' => $data['variables'] ?? $automation->variables,
]);
return $automation->fresh();

View file

@ -17,9 +17,10 @@ public function __construct(private ExpressionResolver $resolver) {}
public function __invoke(AutomationRun $run, array $config): NodeRunResult
{
$field = $this->resolver->resolve(data_get($config, 'field', ''), $run->context ?? []);
$context = $run->resolverContext();
$field = $this->resolver->resolve(data_get($config, 'field', ''), $context);
$operator = Operator::from(data_get($config, 'operator', 'equals'));
$value = (string) data_get($config, 'value', '');
$value = $this->resolver->resolve((string) data_get($config, 'value', ''), $context);
$matched = match ($operator) {
Operator::Contains => str_contains($field, $value),

View file

@ -9,9 +9,12 @@
use App\Jobs\Automation\ProcessAutomationNode;
use App\Models\AutomationNodeState;
use App\Models\AutomationRun;
use App\Services\Automation\ExpressionResolver;
use App\Services\Brand\SafeHttpFetcher;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use SimpleXMLElement;
use Throwable;
@ -32,14 +35,28 @@ class RunFetchRssNode
{
private const ITEM_HANDLE = 'default';
public function __construct(
private ExpressionResolver $resolver,
private SafeHttpFetcher $safeHttp,
) {}
public function __invoke(AutomationRun $run, array $config): NodeRunResult
{
$feedUrl = (string) data_get($config, 'feed_url', '');
$feedUrl = $this->resolver->resolve((string) data_get($config, 'feed_url', ''), $run->resolverContext());
if ($feedUrl === '') {
return NodeRunResult::failed('Fetch RSS node missing feed_url.');
}
try {
$this->safeHttp->guardAgainstSsrf($feedUrl);
} catch (RuntimeException) {
return NodeRunResult::failed(__('automations.errors.url_not_allowed'), [
'reason' => 'url_not_allowed',
'url' => $feedUrl,
]);
}
$response = Http::get($feedUrl);
if (! $response->successful()) {

View file

@ -15,6 +15,7 @@
use App\Models\Workspace;
use App\Services\Ai\RecordAiUsage;
use App\Services\Automation\ExpressionResolver;
use App\Services\Automation\GenerateNodeValidator;
use App\Services\Image\PostImagePipeline;
use Illuminate\Support\Facades\Log;
@ -26,7 +27,7 @@ public function __construct(
public function __invoke(AutomationRun $run, array $config): NodeRunResult
{
$context = $run->context ?? [];
$context = $run->resolverContext();
$prompt = $this->resolver->resolve(data_get($config, 'prompt_template', ''), $context);
$accountsConfig = $this->resolveAccountsConfig($config);
@ -238,26 +239,25 @@ private function humanize(Workspace $workspace, array $structured, string $forma
*/
public function deriveFormat(array $accountsConfig, array $config): array
{
$carouselCapable = [
ContentType::InstagramFeed->value,
ContentType::LinkedInCarousel->value,
ContentType::LinkedInPageCarousel->value,
ContentType::PinterestCarousel->value,
ContentType::TikTokPhoto->value,
];
$hasCarouselAccount = false;
// Multi-image capability comes from the same ContentType rules the
// publish flow uses — never a hardcoded list — so facebook_post,
// tiktok_photo, linkedin_carousel etc. are all recognised. We cap the
// slide count at MAX_GENERATED_IMAGES and at the tightest selected
// account's limit.
$maxImagesAcross = 0;
foreach ($accountsConfig as $entry) {
if (in_array(data_get($entry, 'content_type'), $carouselCapable, strict: true)) {
$hasCarouselAccount = true;
break;
$contentType = ContentType::tryFrom((string) data_get($entry, 'content_type'));
if ($contentType instanceof ContentType && $contentType->supportsImage() && $contentType->maxMediaCount() > 1) {
$maxImagesAcross = max($maxImagesAcross, $contentType->maxMediaCount());
}
}
$targetSlideCount = (int) data_get($config, 'target_slide_count', 1);
if ($hasCarouselAccount && $targetSlideCount > 1) {
return ['format' => 'carousel', 'slide_count' => $targetSlideCount];
if ($maxImagesAcross > 1 && $targetSlideCount > 1) {
$cap = min(GenerateNodeValidator::MAX_GENERATED_IMAGES, $maxImagesAcross);
return ['format' => 'carousel', 'slide_count' => min($targetSlideCount, $cap)];
}
return ['format' => 'single', 'slide_count' => 1];

View file

@ -10,10 +10,12 @@
use App\Models\AutomationNodeState;
use App\Models\AutomationRun;
use App\Services\Automation\ExpressionResolver;
use App\Services\Brand\SafeHttpFetcher;
use Carbon\CarbonImmutable;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use Throwable;
/**
@ -35,7 +37,10 @@ class RunHttpRequestNode
{
private const ITEM_HANDLE = 'default';
public function __construct(private ExpressionResolver $resolver) {}
public function __construct(
private ExpressionResolver $resolver,
private SafeHttpFetcher $safeHttp,
) {}
public function __invoke(AutomationRun $run, array $config): NodeRunResult
{
@ -45,13 +50,23 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
$itemKeyPath = data_get($config, 'item_key_path');
$itemDatePath = data_get($config, 'item_date_path');
$nodeId = (string) $run->current_node_id;
$context = $run->context ?? [];
$context = $run->resolverContext();
if ($url === '') {
return NodeRunResult::failed('HTTP request node missing url.');
}
$resolvedUrl = $this->resolver->resolve($url, $context);
try {
$this->safeHttp->guardAgainstSsrf($resolvedUrl);
} catch (RuntimeException) {
return NodeRunResult::failed(__('automations.errors.url_not_allowed'), [
'reason' => 'url_not_allowed',
'url' => $resolvedUrl,
]);
}
$request = $this->buildRequest($config, $context);
$body = $this->buildJsonBody($method, $config, $context);

View file

@ -7,23 +7,38 @@
use App\DataTransferObjects\Automation\NodeRunResult;
use App\Models\AutomationRun;
use App\Services\Automation\ExpressionResolver;
use App\Services\Brand\SafeHttpFetcher;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class RunWebhookNode
{
public function __construct(private ExpressionResolver $resolver) {}
public function __construct(
private ExpressionResolver $resolver,
private SafeHttpFetcher $safeHttp,
) {}
public function __invoke(AutomationRun $run, array $config): NodeRunResult
{
$url = $this->resolver->resolve($config['url'] ?? '', $run->context ?? []);
$context = $run->resolverContext();
$url = $this->resolver->resolve($config['url'] ?? '', $context);
$method = strtoupper($config['method'] ?? 'POST');
try {
$this->safeHttp->guardAgainstSsrf($url);
} catch (RuntimeException) {
return NodeRunResult::failed(__('automations.errors.url_not_allowed'), [
'reason' => 'url_not_allowed',
'url' => $url,
]);
}
$headers = [];
foreach ($config['headers'] ?? [] as $k => $v) {
$headers[$k] = $this->resolver->resolve((string) $v, $run->context ?? []);
$headers[$k] = $this->resolver->resolve((string) $v, $context);
}
$payloadJson = $this->resolver->resolve($config['payload_template'] ?? '{}', $run->context ?? []);
$payloadJson = $this->resolver->resolve($config['payload_template'] ?? '{}', $context);
$trimmedPayload = trim($payloadJson);
if ($trimmedPayload !== '' && $trimmedPayload !== 'null') {
@ -40,6 +55,12 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
$payload = [];
}
if ($run->is_dry_run) {
return NodeRunResult::completed(output: [
'webhook' => ['method' => $method, 'url' => $url, 'dry_run' => true],
]);
}
$response = Http::withHeaders($headers)
->withUserAgent(config('trypost.user_agent'))
->send($method, $url, ['json' => $payload]);

View file

@ -11,7 +11,6 @@
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
#[Signature('automation:process-delays')]
#[Description('Wake up runs that finished their delay window')]
@ -25,17 +24,23 @@ public function handle(AdvanceAutomationRun $advance): int
->where(fn ($query) => $query
->where('is_manual', true)
->orWhereHas('automation', fn ($inner) => $inner->where('status', AutomationStatus::Active)))
->lockForUpdate()
->chunkById(50, function ($runs) use ($advance) {
DB::transaction(function () use ($runs, $advance) {
foreach ($runs as $run) {
$run->update([
foreach ($runs as $run) {
$claimed = AutomationRun::query()
->whereKey($run->id)
->where('status', Status::Waiting)
->update([
'status' => Status::Running,
'next_action_at' => null,
]);
$advance($run, $run->current_node_id);
if ($claimed === 0) {
continue;
}
});
$run->refresh();
$advance($run, $run->current_node_id);
}
});
return self::SUCCESS;

View file

@ -8,6 +8,8 @@
use App\Enums\Automation\Node\Type as NodeType;
use App\Enums\Automation\Publish\Mode as PublishMode;
use App\Enums\Automation\Trigger\Type as TriggerType;
use App\Services\Automation\GenerateNodeValidator;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@ -38,6 +40,9 @@ public function rules(): array
'connections.*.target' => ['required', 'string'],
'connections.*.source_handle' => ['nullable', 'string'],
'connections.*.target_handle' => ['nullable', 'string'],
'variables' => ['sometimes', 'array', 'max:50'],
'variables.*.key' => ['required', 'string', 'max:60', 'regex:/^[A-Za-z_][A-Za-z0-9_]*$/', 'distinct'],
'variables.*.value' => ['nullable', 'string'],
];
// Per-node data validation. We build these dynamically so each node's
@ -56,6 +61,36 @@ public function rules(): array
return $rules;
}
/**
* Block saving a Generate node whose intended image count doesn't fit a
* selected account's content-type (mirrors the inline frontend validation),
* keyed so the frontend surfaces it under that node's accounts field.
*/
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
$nodes = $this->input('nodes', []);
if (! is_array($nodes)) {
return;
}
$generateValidator = app(GenerateNodeValidator::class);
foreach ($nodes as $i => $node) {
if (data_get($node, 'type') !== NodeType::Generate->value) {
continue;
}
$issue = $generateValidator->issueFor((array) data_get($node, 'data', []));
if ($issue !== null) {
$validator->errors()->add("nodes.{$i}.data.accounts", $issue);
}
}
});
}
/**
* @return array<string, mixed>
*/
@ -73,7 +108,6 @@ public function attributes(): array
'nodes.*.data.method' => 'method',
'nodes.*.data.trigger_type' => 'trigger type',
'nodes.*.data.prompt_template' => 'prompt template',
'nodes.*.data.image_source' => 'image source',
'nodes.*.data.accounts' => 'accounts',
];
}
@ -109,8 +143,7 @@ private function dataRulesForNodeType(?string $type): array
NodeType::Generate->value => [
'accounts' => ['required', 'array', 'min:1'],
'prompt_template' => ['required', 'string'],
'image_source' => ['required', Rule::in(['ai', 'unsplash', 'none'])],
'target_slide_count' => ['nullable', 'integer', 'min:1', 'max:20'],
'target_slide_count' => ['nullable', 'integer', 'min:1', 'max:'.GenerateNodeValidator::MAX_GENERATED_IMAGES],
],
NodeType::Delay->value => [
'duration' => ['required', 'integer', 'min:1'],

View file

@ -22,6 +22,7 @@ public function toArray(Request $request): array
'status' => $this->status->value,
'nodes' => $this->maskSensitiveNodeFields($this->nodes ?? []),
'connections' => $this->connections ?? [],
'variables' => $this->maskVariables($this->variables ?? []),
'activated_at' => $this->activated_at,
'paused_at' => $this->paused_at,
'created_at' => $this->created_at,
@ -50,4 +51,24 @@ private function maskSensitiveNodeFields(array $nodes): array
return $nodes;
}
/**
* Replace stored variable values with the placeholder so secrets never
* leave the server. The frontend references variables by key
* (`{{ variables.KEY }}`), so the masked value doesn't hinder reuse, and
* re-saving the placeholder keeps the stored ciphertext.
*
* @param array<int, array<string, mixed>> $variables
* @return array<int, array<string, mixed>>
*/
private function maskVariables(array $variables): array
{
foreach ($variables as &$variable) {
if (data_get($variable, 'value') !== null && data_get($variable, 'value') !== '') {
$variable['value'] = Automation::SENSITIVE_PLACEHOLDER;
}
}
return $variables;
}
}

View file

@ -33,6 +33,7 @@ class Automation extends Model
'status' => Status::class,
'nodes' => 'array',
'connections' => 'array',
'variables' => 'array',
'activated_at' => 'datetime',
'paused_at' => 'datetime',
];
@ -44,9 +45,35 @@ protected static function booted(): void
$automation->nodes ?? [],
$automation->getOriginal('nodes') ?? [],
);
$automation->variables = self::encryptVariables(
$automation->variables ?? [],
$automation->getOriginal('variables') ?? [],
);
});
}
/**
* Workflow variables decrypted into a `key => value` map for use during a
* run (e.g. `{{ variables.API_KEY }}` resolution). Encrypted at rest and
* never returned to the frontend in plain text.
*
* @return array<string, string>
*/
public function resolvedVariables(): array
{
$resolved = [];
foreach ($this->variables ?? [] as $variable) {
$key = data_get($variable, 'key');
if (! is_string($key) || $key === '') {
continue;
}
$resolved[$key] = self::decryptValue((string) data_get($variable, 'value', ''));
}
return $resolved;
}
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
@ -104,6 +131,52 @@ private static function encryptSensitiveFields(array $incoming, array|string $or
return $incoming;
}
/**
* Reconciles workflow variable values exactly like node credentials, matched
* by variable `key`: a PLACEHOLDER value keeps the existing ciphertext,
* plaintext gets encrypted, already-encrypted strings pass through.
*
* @param array<int, array<string, mixed>> $incoming
* @param array<int, array<string, mixed>>|string $original
* @return array<int, array<string, mixed>>
*/
private static function encryptVariables(array $incoming, array|string $original): array
{
$original = is_array($original) ? $original : (json_decode($original, true) ?: []);
$originalByKey = collect($original)->keyBy('key');
foreach ($incoming as &$variable) {
$value = data_get($variable, 'value');
if (! is_string($value) || $value === '') {
continue;
}
if ($value === self::SENSITIVE_PLACEHOLDER) {
$variable['value'] = (string) data_get($originalByKey->get($variable['key'] ?? null), 'value', '');
continue;
}
if (self::looksEncrypted($value)) {
continue;
}
$variable['value'] = Crypt::encryptString($value);
}
return $incoming;
}
private static function decryptValue(string $value): string
{
if ($value === '') {
return '';
}
try {
return Crypt::decryptString($value);
} catch (Throwable) {
return $value;
}
}
/**
* Quick check for Laravel's `Crypt::encryptString` output without paying
* the cost of a full decrypt attempt. Laravel wraps payloads as base64

View file

@ -38,6 +38,23 @@ public function automation(): BelongsTo
return $this->belongsTo(Automation::class);
}
/**
* Context for template (`{{ ... }}`) resolution: the run context plus the
* automation's workflow variables, merged in-memory. Variables are NEVER
* persisted into the run context (they're encrypted at rest and would
* otherwise leak in plaintext via the run/node-run API), so we compute this
* on demand at resolve time only.
*
* @return array<string, mixed>
*/
public function resolverContext(): array
{
return array_merge(
$this->context ?? [],
['variables' => $this->automation->resolvedVariables()],
);
}
public function triggerItem(): BelongsTo
{
return $this->belongsTo(AutomationTriggerItem::class, 'trigger_item_id');

View file

@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Services\Automation;
use App\Enums\PostPlatform\ContentType;
/**
* Backend mirror of the Generate node's frontend compliance: validates that the
* number of AI images intended for each selected account fits that account's
* content-type media rules. Uses the ContentType enum as the single source of
* truth (same `maxMediaCount`/`supportsImage`/`requiresMedia` the publish flow
* uses) and reuses the post editor's compliance messages so the wording matches
* exactly. AI image generation is capped at MAX_GENERATED_IMAGES regardless of
* how many a platform technically allows.
*/
final class GenerateNodeValidator
{
public const MAX_GENERATED_IMAGES = 10;
/**
* First compliance issue for a generate node's config, or null when valid.
*
* @param array<string, mixed> $config
*/
public function issueFor(array $config): ?string
{
$accounts = data_get($config, 'accounts');
if (! is_array($accounts) || $accounts === []) {
return null;
}
$imageCount = $this->intendedImageCount($config, $accounts);
foreach ($accounts as $entry) {
$contentType = ContentType::tryFrom((string) data_get($entry, 'content_type'));
if (! $contentType instanceof ContentType) {
continue;
}
$issue = $this->issueForAccount($contentType, $imageCount);
if ($issue !== null) {
return $issue;
}
}
return null;
}
/**
* Mirrors the frontend: the carousel slide count when any selected account
* supports multiple images, otherwise a single image when enabled.
*
* @param array<string, mixed> $config
* @param array<int, mixed> $accounts
*/
private function intendedImageCount(array $config, array $accounts): int
{
foreach ($accounts as $entry) {
$contentType = ContentType::tryFrom((string) data_get($entry, 'content_type'));
if ($contentType instanceof ContentType && $contentType->supportsImage() && $contentType->maxMediaCount() > 1) {
return (int) data_get($config, 'target_slide_count', 2);
}
}
return (bool) data_get($config, 'include_image', true) ? 1 : 0;
}
private function issueForAccount(ContentType $contentType, int $imageCount): ?string
{
if ($contentType->requiresMedia() && $imageCount === 0) {
return __('posts.edit.compliance.requires_media');
}
if ($imageCount > 0 && ! $contentType->supportsImage()) {
return __('posts.edit.compliance.no_images');
}
$max = min(self::MAX_GENERATED_IMAGES, $contentType->maxMediaCount());
if ($imageCount > $max) {
return __('posts.edit.compliance.too_many_files', ['max' => (string) $max]);
}
return null;
}
}

View file

@ -0,0 +1,28 @@
<?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::table('automations', function (Blueprint $table) {
$table->json('variables')->nullable()->after('connections');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('automations', function (Blueprint $table) {
$table->dropColumn('variables');
});
}
};

View file

@ -14,12 +14,33 @@
'pause' => 'Pause',
'delete' => 'Delete',
'retry' => 'Retry',
'add_node' => 'Add node',
],
'tabs' => [
'build' => 'Build',
'variables' => 'Variables',
'test' => 'Test',
],
'categories' => [
'sources' => 'Sources',
'content' => 'Content',
'flow' => 'Flow',
'output' => 'Output',
],
'variables' => [
'title' => 'Workflow variables',
'hint' => 'Reusable values referenced anywhere with {{ variables.KEY }}. Stored encrypted.',
'empty' => 'No variables yet.',
'key' => 'Key',
'value' => 'Value',
'key_placeholder' => 'API_KEY',
'value_placeholder' => 'Value',
'add' => 'New variable',
],
'test' => [
'title' => 'Test run',
'description' => 'Runs the automation end-to-end using a synthesized trigger payload. Useful for validating each node without waiting for the real schedule or feed.',
'starting' => 'Starting test run…',
'in_progress' => 'In progress',
@ -53,7 +74,6 @@
'name' => 'Name',
'status' => 'Status',
'created' => 'Created',
'actions' => 'Actions',
],
],
@ -76,7 +96,6 @@
'pause_error_fallback' => 'Could not pause automation.',
'save_error_fallback' => 'Could not save automation.',
'save_success' => 'Automation saved.',
'config_title' => ':type config',
'empty_canvas_title' => 'Start building your automation',
'empty_canvas_description' => 'Drag a node from the left panel to get started.',
'name_placeholder' => 'Untitled automation',
@ -253,6 +272,7 @@
'no_generated_post' => 'No generated post found on run.',
'webhook_server_error' => 'Webhook server error.',
'webhook_invalid_payload_json' => 'The payload template is not valid JSON.',
'url_not_allowed' => 'The request URL points to a private or unreachable address and was blocked.',
'node_no_longer_exists' => 'Node :node_id no longer exists in the automation.',
'no_trigger_connection' => 'No node connected to the Trigger node.',
],

View file

@ -53,5 +53,6 @@
'actions' => [
'copy' => 'Copy',
'copied' => 'Copied',
'copy_failed' => 'Failed to copy to clipboard',
],
];

View file

@ -14,12 +14,33 @@
'pause' => 'Pausar',
'delete' => 'Eliminar',
'retry' => 'Reintentar',
'add_node' => 'Agregar nodo',
],
'tabs' => [
'build' => 'Construir',
'variables' => 'Variables',
'test' => 'Probar',
],
'categories' => [
'sources' => 'Fuentes',
'content' => 'Contenido',
'flow' => 'Flujo',
'output' => 'Salida',
],
'variables' => [
'title' => 'Variables del workflow',
'hint' => 'Valores reutilizables referenciados en cualquier lugar con {{ variables.KEY }}. Almacenados cifrados.',
'empty' => 'Aún no hay variables.',
'key' => 'Clave',
'value' => 'Valor',
'key_placeholder' => 'API_KEY',
'value_placeholder' => 'Valor',
'add' => 'Nueva variable',
],
'test' => [
'title' => 'Ejecución de prueba',
'description' => 'Ejecuta la automatización de punta a punta usando un payload de disparo sintético. Útil para validar cada nodo sin esperar el cronograma o el feed real.',
'starting' => 'Iniciando ejecución de prueba…',
'in_progress' => 'En progreso',
@ -53,7 +74,6 @@
'name' => 'Nombre',
'status' => 'Estado',
'created' => 'Creada',
'actions' => 'Acciones',
],
],
@ -76,7 +96,6 @@
'pause_error_fallback' => 'No se pudo pausar la automatización.',
'save_error_fallback' => 'No se pudo guardar la automatización.',
'save_success' => 'Automatización guardada.',
'config_title' => 'Config. de :type',
'empty_canvas_title' => 'Empieza a construir tu automatización',
'empty_canvas_description' => 'Arrastra un nodo del panel izquierdo para empezar.',
'name_placeholder' => 'Automatización sin título',
@ -253,6 +272,7 @@
'no_generated_post' => 'No se encontró un post generado en la ejecución.',
'webhook_server_error' => 'Error del servidor del webhook.',
'webhook_invalid_payload_json' => 'La plantilla de payload no es un JSON válido.',
'url_not_allowed' => 'La URL de la petición apunta a una dirección privada o inaccesible y fue bloqueada.',
'node_no_longer_exists' => 'El nodo :node_id ya no existe en la automatización.',
'no_trigger_connection' => 'Ningún nodo está conectado al nodo disparador.',
],

View file

@ -53,5 +53,6 @@
'actions' => [
'copy' => 'Copiar',
'copied' => 'Copiado',
'copy_failed' => 'No se pudo copiar al portapapeles',
],
];

View file

@ -14,12 +14,33 @@
'pause' => 'Pausar',
'delete' => 'Excluir',
'retry' => 'Tentar novamente',
'add_node' => 'Adicionar nó',
],
'tabs' => [
'build' => 'Montar',
'variables' => 'Variáveis',
'test' => 'Testar',
],
'categories' => [
'sources' => 'Fontes',
'content' => 'Conteúdo',
'flow' => 'Fluxo',
'output' => 'Saída',
],
'variables' => [
'title' => 'Variáveis do workflow',
'hint' => 'Valores reutilizáveis referenciados em qualquer lugar com {{ variables.KEY }}. Armazenados encriptados.',
'empty' => 'Nenhuma variável ainda.',
'key' => 'Chave',
'value' => 'Valor',
'key_placeholder' => 'API_KEY',
'value_placeholder' => 'Valor',
'add' => 'Nova variável',
],
'test' => [
'title' => 'Execução de teste',
'description' => 'Executa a automação ponta a ponta usando um payload de gatilho sintético. Útil pra validar cada nó sem esperar o agendamento ou o feed real.',
'starting' => 'Iniciando execução de teste…',
'in_progress' => 'Em andamento',
@ -53,7 +74,6 @@
'name' => 'Nome',
'status' => 'Status',
'created' => 'Criada em',
'actions' => 'Ações',
],
],
@ -76,7 +96,6 @@
'pause_error_fallback' => 'Não foi possível pausar a automação.',
'save_error_fallback' => 'Não foi possível salvar a automação.',
'save_success' => 'Automação salva.',
'config_title' => 'Configuração :type',
'empty_canvas_title' => 'Comece a construir sua automação',
'empty_canvas_description' => 'Arraste um nó do painel esquerdo para começar.',
'name_placeholder' => 'Automação sem título',
@ -253,6 +272,7 @@
'no_generated_post' => 'Nenhum post gerado encontrado para esta execução.',
'webhook_server_error' => 'Erro no servidor do webhook.',
'webhook_invalid_payload_json' => 'O template do payload não é um JSON válido.',
'url_not_allowed' => 'A URL da requisição aponta para um endereço privado ou inacessível e foi bloqueada.',
'node_no_longer_exists' => 'O nó :node_id não existe mais nesta automação.',
'no_trigger_connection' => 'Nenhum nó conectado ao nó de trigger.',
],

View file

@ -53,5 +53,6 @@
'actions' => [
'copy' => 'Copiar',
'copied' => 'Copiado',
'copy_failed' => 'Falha ao copiar para a área de transferência',
],
];

View file

@ -225,6 +225,7 @@ .automation-node__icon-tile--rose { background: #ffe4e6; color: #be123c; }
.automation-node__icon-tile--emerald { background: #d1fae5; color: #047857; }
.automation-node__icon-tile--slate { background: #e2e8f0; color: #334155; }
.automation-node__icon-tile--zinc { background: #e4e4e7; color: #27272a; }
.automation-node__icon-tile--cyan { background: #cffafe; color: #155e75; }
.automation-node--accent-violet .automation-node__header { background: #f5f3ff; }
.automation-node--accent-blue .automation-node__header { background: #eff6ff; }
@ -233,6 +234,7 @@ .automation-node--accent-rose .automation-node__header { background: #fff1f2;
.automation-node--accent-emerald .automation-node__header { background: #ecfdf5; }
.automation-node--accent-slate .automation-node__header { background: #f1f5f9; }
.automation-node--accent-zinc .automation-node__header { background: #f4f4f5; }
.automation-node--accent-cyan .automation-node__header { background: #ecfeff; }
.automation-node__title {
min-width: 0;

View file

@ -0,0 +1,173 @@
<script setup lang="ts">
import { IconAlertCircle, IconCircleCheck } from '@tabler/icons-vue';
import { computed } from '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 { Avatar } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
import type { Channel } from '@/types/channel';
import type { MediaItem } from '@/types/media';
import { Platform } from '@/types/platform';
import { PostPlatformStatus } from '@/types/post';
const props = withDefaults(defineProps<{
channels: Channel[];
selectedIds: string[];
media?: MediaItem[];
videoDurationSec?: number | null;
disabled?: boolean;
previewOnly?: boolean;
}>(), {
media: () => [],
videoDurationSec: null,
disabled: false,
previewOnly: false,
});
const emit = defineEmits<{
toggle: [id: string];
'update:contentType': [id: string, value: string];
'update:meta': [id: string, value: Record<string, any>];
}>();
const isSelected = (id: string): boolean => props.selectedIds.includes(id);
const selectedChannels = computed(() => props.channels.filter((channel) => isSelected(channel.id)));
</script>
<template>
<div class="space-y-6">
<div class="flex flex-wrap gap-3">
<TooltipProvider v-for="channel in channels" :key="channel.id" :delay-duration="200">
<Tooltip>
<TooltipTrigger as-child>
<button
type="button"
class="flex w-20 cursor-pointer flex-col items-center gap-1.5 transition-opacity"
:class="[
channel.issue && !isSelected(channel.id) ? 'cursor-not-allowed opacity-40' : '',
channel.issue && isSelected(channel.id) ? 'opacity-100' : '',
!channel.issue ? 'opacity-100 hover:opacity-90' : '',
]"
:disabled="Boolean(channel.issue) && !isSelected(channel.id)"
@click="emit('toggle', channel.id)"
>
<div class="relative">
<Avatar
:src="channel.avatarUrl"
:name="channel.displayName"
class="size-10 shrink-0 rounded-full border-2"
:class="[
channel.issue && isSelected(channel.id) ? 'border-rose-500 shadow-2xs' : '',
!channel.issue && isSelected(channel.id) ? 'border-foreground shadow-2xs' : '',
!isSelected(channel.id) ? 'border-foreground/20' : '',
]"
/>
<span class="absolute -bottom-1 -right-1 inline-flex size-5 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card shadow-2xs">
<img :src="getPlatformLogo(channel.platform)" :alt="channel.platform" class="size-full object-cover" />
</span>
<Badge
v-if="channel.issue && isSelected(channel.id)"
variant="destructive"
class="absolute -top-1 -right-1 h-4 w-4 p-0"
>
<IconAlertCircle class="h-2.5 w-2.5" />
</Badge>
<Badge v-else-if="channel.status === PostPlatformStatus.Published" variant="success" class="absolute -top-1 -right-1 h-4 w-4 p-0">
<IconCircleCheck class="h-2.5 w-2.5" />
</Badge>
<Badge v-else-if="channel.status === PostPlatformStatus.Failed" variant="destructive" class="absolute -top-1 -right-1 h-4 w-4 p-0 text-[9px]">!</Badge>
</div>
<span
class="line-clamp-2 text-center text-xs leading-tight"
:class="isSelected(channel.id) ? 'font-bold text-foreground' : 'font-medium text-foreground/70'"
>
{{ channel.displayName }}
</span>
</button>
</TooltipTrigger>
<TooltipContent>
<div class="space-y-0.5 text-xs">
<p class="font-semibold">
{{ channel.displayName }}<span v-if="channel.username" class="font-normal opacity-80">&nbsp;·&nbsp;@{{ channel.username }}</span>
</p>
<p class="opacity-70">{{ getPlatformLabel(channel.platform) }}</p>
<p v-if="channel.issue" class="mt-1 max-w-xs text-destructive-foreground/90">
{{ channel.issue }}
</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<slot />
<template v-for="channel in selectedChannels" :key="channel.id">
<InstagramSettings
v-if="channel.platform === Platform.Instagram || channel.platform === Platform.InstagramFacebook"
:social-account="channel.socialAccount"
:content-type="channel.contentType"
:media="media"
:meta="channel.meta"
:disabled="disabled"
:preview-only="previewOnly"
@update:content-type="emit('update:contentType', channel.id, $event)"
@update:meta="emit('update:meta', channel.id, $event)"
/>
<FacebookSettings
v-else-if="channel.platform === Platform.Facebook"
:social-account="channel.socialAccount"
:content-type="channel.contentType"
:media="media"
:meta="channel.meta"
:disabled="disabled"
:preview-only="previewOnly"
@update:content-type="emit('update:contentType', channel.id, $event)"
@update:meta="emit('update:meta', channel.id, $event)"
/>
<TikTokSettings
v-else-if="channel.platform === Platform.TikTok"
:social-account="channel.socialAccount"
:publish-config="channel.publishConfig ?? null"
:creator-info="channel.creatorInfo ?? null"
:video-duration-sec="videoDurationSec"
:content-type="channel.contentType"
:content-type-error="channel.contentTypeError"
:meta="channel.meta"
:disabled="disabled"
:preview-only="previewOnly"
@update:content-type="emit('update:contentType', channel.id, $event)"
@update:meta="emit('update:meta', channel.id, $event)"
/>
<PinterestSettings
v-else-if="channel.platform === Platform.Pinterest"
:social-account="channel.socialAccount"
:content-type="channel.contentType"
:media="media"
:boards="channel.boards ?? []"
:meta="channel.meta"
:disabled="disabled"
:preview-only="previewOnly"
@update:content-type="emit('update:contentType', channel.id, $event)"
@update:meta="emit('update:meta', channel.id, $event)"
/>
<LinkedInSettings
v-else-if="channel.platform === Platform.LinkedIn || channel.platform === Platform.LinkedInPage"
:social-account="channel.socialAccount"
:platform="channel.platform"
:content-type="channel.contentType"
:media="media"
:disabled="disabled"
:preview-only="previewOnly"
@update:content-type="emit('update:contentType', channel.id, $event)"
/>
</template>
</div>
</template>

View file

@ -3,10 +3,12 @@ import { indentWithTab } from '@codemirror/commands';
import { json } from '@codemirror/lang-json';
import { EditorState } from '@codemirror/state';
import { EditorView, keymap, placeholder as placeholderExt } from '@codemirror/view';
import { IconCopy } from '@tabler/icons-vue';
import { basicSetup } from 'codemirror';
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import debounce from '@/debounce';
import { copyToClipboard } from '@/lib/utils';
const props = withDefaults(
defineProps<{
@ -155,5 +157,18 @@ onBeforeUnmount(() => {
</script>
<template>
<div ref="editorContainer" class="code-editor h-full w-full" />
<div class="relative h-full w-full">
<div ref="editorContainer" class="code-editor h-full w-full" />
<button
v-if="modelValue"
type="button"
class="absolute right-2 top-2 z-10 inline-flex size-7 items-center justify-center rounded-md border-2 border-foreground bg-card 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)]"
:title="$t('common.actions.copy')"
:aria-label="$t('common.actions.copy')"
@click="copyToClipboard(modelValue)"
>
<IconCopy class="size-3.5" stroke-width="2.5" />
</button>
</div>
</template>

View file

@ -1,9 +1,8 @@
<script setup lang="ts">
import { IconCheck, IconCopy } from '@tabler/icons-vue';
import { 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 { computed } from 'vue';
import { copyToClipboard } from '@/lib/utils';
@ -24,14 +23,6 @@ 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>
@ -40,10 +31,10 @@ const handleCopy = async () => {
<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"
@click="copyToClipboard(serialized)"
>
<component :is="justCopied ? IconCheck : IconCopy" class="size-3.5" stroke-width="2.5" />
{{ justCopied ? $t('common.actions.copied') : $t('common.actions.copy') }}
<IconCopy class="size-3.5" stroke-width="2.5" />
{{ $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>

View file

@ -0,0 +1,88 @@
<script setup lang="ts">
import {
IconCircleX,
IconClock,
IconGitBranch,
IconGripVertical,
IconRss,
IconSend,
IconSparkles,
IconWebhook,
IconWorld,
} from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed } from 'vue';
import { NodeType } from '@/types/automation/node-type';
const categories = computed(() => [
{
title: trans('automations.categories.sources'),
nodes: [
{ type: NodeType.FetchRss, label: trans('automations.nodes.fetch_rss'), icon: IconRss, accent: 'amber' },
{ type: NodeType.HttpRequest, label: trans('automations.nodes.http_request'), icon: IconWorld, accent: 'slate' },
],
},
{
title: trans('automations.categories.content'),
nodes: [
{ type: NodeType.Generate, label: trans('automations.nodes.generate'), icon: IconSparkles, accent: 'blue' },
],
},
{
title: trans('automations.categories.flow'),
nodes: [
{ type: NodeType.Condition, label: trans('automations.nodes.condition'), icon: IconGitBranch, accent: 'rose' },
{ type: NodeType.Delay, label: trans('automations.nodes.delay'), icon: IconClock, accent: 'cyan' },
{ type: NodeType.End, label: trans('automations.nodes.end'), icon: IconCircleX, accent: 'zinc' },
],
},
{
title: trans('automations.categories.output'),
nodes: [
{ type: NodeType.Publish, label: trans('automations.nodes.publish'), icon: IconSend, accent: 'emerald' },
{ type: NodeType.Webhook, label: trans('automations.nodes.webhook'), icon: IconWebhook, accent: 'slate' },
],
},
]);
const accentClasses: Record<string, { tint: string; text: string }> = {
violet: { tint: 'bg-violet-200', text: 'text-violet-900' },
blue: { tint: 'bg-blue-200', text: 'text-blue-900' },
amber: { tint: 'bg-amber-200', text: 'text-amber-900' },
rose: { tint: 'bg-rose-200', text: 'text-rose-900' },
emerald: { tint: 'bg-emerald-200', text: 'text-emerald-900' },
slate: { tint: 'bg-slate-200', text: 'text-slate-900' },
zinc: { tint: 'bg-zinc-200', text: 'text-zinc-900' },
cyan: { tint: 'bg-cyan-200', text: 'text-cyan-900' },
};
const onDragStart = (event: DragEvent, nodeType: string) => {
if (!event.dataTransfer) return;
event.dataTransfer.setData('application/automation-node-type', nodeType);
event.dataTransfer.effectAllowed = 'move';
};
</script>
<template>
<div class="flex flex-col gap-5">
<div v-for="category in categories" :key="category.title" class="flex flex-col gap-2">
<p class="px-0.5 text-[11px] font-black uppercase tracking-widest text-foreground/45">
{{ category.title }}
</p>
<button
v-for="option in category.nodes"
:key="option.type"
draggable="true"
class="group flex cursor-grab items-center gap-2.5 rounded-xl border-2 border-foreground bg-card p-2.5 text-left text-sm font-bold text-foreground shadow-[2px_2px_0_var(--foreground)] transition-all hover:-translate-x-px hover:-translate-y-px hover:shadow-[3px_3px_0_var(--foreground)] active:translate-x-0 active:translate-y-0 active:rotate-[-1deg] active:cursor-grabbing active:shadow-[1px_1px_0_var(--foreground)]"
@dragstart="onDragStart($event, option.type)"
>
<div :class="['flex size-7 -rotate-3 items-center justify-center rounded-md border-2 border-foreground', accentClasses[option.accent].tint]">
<component :is="option.icon" :size="14" :class="accentClasses[option.accent].text" />
</div>
<span class="flex-1">{{ option.label }}</span>
<IconGripVertical :size="16" class="shrink-0 text-foreground/30 transition-colors group-hover:text-foreground/55" />
</button>
</div>
</div>
</template>

View file

@ -0,0 +1,71 @@
<script setup lang="ts">
import { IconArrowLeft, IconTrash } from '@tabler/icons-vue';
import BuildPanel from '@/components/automations/BuildPanel.vue';
import TestRunPanel from '@/components/automations/TestRunPanel.vue';
import VariablesPanel from '@/components/automations/VariablesPanel.vue';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import type { AutomationVariable } from '@/types/automation/automation';
defineProps<{
automationId: string;
beforeRun?: () => Promise<boolean> | boolean;
editing?: boolean;
nodeTitle?: string;
deletable?: boolean;
}>();
const emit = defineEmits<{ back: []; delete: [] }>();
const activeTab = defineModel<string>('tab', { default: 'build' });
const variables = defineModel<AutomationVariable[]>('variables', { default: () => [] });
</script>
<template>
<aside class="flex w-[26rem] flex-shrink-0 flex-col py-3 pl-2 pr-3">
<div class="flex h-full min-h-0 flex-col overflow-hidden rounded-2xl border-2 border-foreground bg-card shadow-[3px_3px_0_var(--foreground)]">
<!-- Editing a node: its config lives inside the same bar, reachable
back to Build via the arrow. -->
<template v-if="editing">
<div class="flex items-center justify-between gap-2 border-b-2 border-foreground/10 px-3 py-3">
<div class="flex min-w-0 items-center gap-1.5">
<Button variant="ghost" size="icon-sm" @click="emit('back')">
<IconArrowLeft class="size-4" />
</Button>
<span class="truncate text-sm font-bold capitalize">{{ nodeTitle }}</span>
</div>
<Button v-if="deletable" variant="ghost" size="icon-sm" @click="emit('delete')">
<IconTrash class="size-4" />
</Button>
</div>
<div class="min-h-0 overflow-y-auto p-4">
<slot name="config" />
</div>
</template>
<!-- Default: Build / Test tabs -->
<Tabs v-else v-model="activeTab" class="flex h-full min-h-0 flex-col">
<TabsList class="flex h-auto w-full gap-2 border-b-2 border-foreground/10 px-4 py-3">
<TabsTrigger value="build" class="flex-1">{{ $t('automations.tabs.build') }}</TabsTrigger>
<TabsTrigger value="variables" class="flex-1">
{{ $t('automations.tabs.variables') }}<span v-if="variables.length"> ({{ variables.length }})</span>
</TabsTrigger>
<TabsTrigger value="test" class="flex-1">{{ $t('automations.tabs.test') }}</TabsTrigger>
</TabsList>
<TabsContent value="build" class="min-h-0 overflow-y-auto p-4">
<BuildPanel />
</TabsContent>
<TabsContent value="variables" class="min-h-0 overflow-y-auto p-4">
<VariablesPanel v-model="variables" />
</TabsContent>
<TabsContent value="test" class="min-h-0 overflow-y-auto">
<TestRunPanel :automation-id="automationId" :before-run="beforeRun" />
</TabsContent>
</Tabs>
</div>
</aside>
</template>

View file

@ -1,15 +1,15 @@
<script setup lang="ts">
import { IconAlertCircle, IconChevronRight, IconCircleCheck, IconCircleDot, IconInfoCircle, IconLoader2, IconPlayerPlay, IconX } from '@tabler/icons-vue';
import { IconAlertCircle, IconChevronRight, IconCircleCheck, IconCircleDot, IconInfoCircle, IconLoader2, IconPlayerPlay } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { ref } from 'vue';
import { toast } from 'vue-sonner';
import { trans } from 'laravel-vue-i18n';
import { showRun as showRunRoute } from '@/actions/App/Http/Controllers/App/AutomationController';
import JsonViewer from '@/components/JsonViewer.vue';
import { useAutomationEcho } from '@/composables/echo/useAutomationEcho';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { useAutomationEcho } from '@/composables/echo/useAutomationEcho';
import { test as testAutomation } from '@/routes/app/automations';
import { showRun as showRunRoute } from '@/actions/App/Http/Controllers/App/AutomationController';
interface NodeRun {
id: string;
@ -35,8 +35,6 @@ interface Run {
const props = defineProps<{ automationId: string; beforeRun?: () => Promise<boolean> | boolean }>();
const open = defineModel<boolean>('open', { default: false });
const isStarting = ref(false);
const realData = ref(false);
const run = ref<Run | null>(null);
@ -95,7 +93,6 @@ const start = async () => {
await fetchRun(runId);
} catch {
toast.error(trans('automations.test.error_starting'));
open.value = false;
} finally {
isStarting.value = false;
}
@ -108,8 +105,6 @@ const runTest = async () => {
await start();
};
const close = () => { open.value = false; };
const statusLabel = (status: string): string => {
const map: Record<string, string> = {
running: trans('automations.test.in_progress'),
@ -139,25 +134,17 @@ const isZeroFetchResult = (nodeRun: NodeRun): boolean => {
</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 class="flex flex-col gap-5 px-5 pt-5 pb-12">
<div class="min-w-0">
<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>
<p class="text-sm text-foreground/60" :class="{ 'mt-2': run?.is_dry_run }">
{{ run?.is_dry_run === false ? $t('automations.test.real_data_hint') : $t('automations.test.description') }}
</p>
</div>
<div class="flex items-center justify-between gap-3 rounded-xl border-2 border-foreground bg-card p-3 shadow-[3px_3px_0_var(--foreground)]">
@ -244,5 +231,5 @@ const isZeroFetchResult = (nodeRun: NodeRun): boolean => {
</div>
</li>
</ul>
</aside>
</div>
</template>

View file

@ -0,0 +1,89 @@
<script setup lang="ts">
import { IconCopy, IconPlus, IconTrash } from '@tabler/icons-vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { copyToClipboard } from '@/lib/utils';
import type { AutomationVariable } from '@/types/automation/automation';
const variables = defineModel<AutomationVariable[]>({ default: () => [] });
const usage = (key: string): string => `{{ variables.${key} }}`;
const addVariable = () => {
variables.value = [...variables.value, { key: '', value: '' }];
};
const removeVariable = (index: number) => {
variables.value = variables.value.filter((_, i) => i !== index);
};
const updateKey = (index: number, key: string) => {
variables.value = variables.value.map((variable, i) => (i === index ? { ...variable, key } : variable));
};
const updateValue = (index: number, value: string) => {
variables.value = variables.value.map((variable, i) => (i === index ? { ...variable, value } : variable));
};
</script>
<template>
<div class="flex flex-col gap-4">
<div>
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/50">{{ $t('automations.variables.title') }}</p>
<p class="mt-1 text-xs text-foreground/60">{{ $t('automations.variables.hint') }}</p>
</div>
<div
v-if="variables.length === 0"
class="rounded-xl border-2 border-dashed border-foreground/25 bg-card/40 p-6 text-center text-sm font-medium text-foreground/60"
>
{{ $t('automations.variables.empty') }}
</div>
<div
v-for="(variable, index) in variables"
:key="index"
class="space-y-2 rounded-xl border-2 border-foreground bg-card p-3 shadow-2xs"
>
<div class="flex items-start gap-2">
<div class="flex-1 space-y-1">
<Label class="text-[11px] font-bold uppercase tracking-wider text-foreground/60">{{ $t('automations.variables.key') }}</Label>
<Input
:model-value="variable.key"
:placeholder="$t('automations.variables.key_placeholder')"
@update:model-value="updateKey(index, String($event))"
/>
</div>
<Button variant="ghost" size="icon-sm" class="mt-6" @click="removeVariable(index)">
<IconTrash class="size-4" />
</Button>
</div>
<div class="space-y-1">
<Label class="text-[11px] font-bold uppercase tracking-wider text-foreground/60">{{ $t('automations.variables.value') }}</Label>
<Input
:model-value="variable.value"
:placeholder="$t('automations.variables.value_placeholder')"
@update:model-value="updateValue(index, String($event))"
/>
</div>
<button
v-if="variable.key"
type="button"
class="inline-flex items-center gap-1.5 font-mono text-xs text-foreground/55 transition-colors hover:text-foreground"
@click="copyToClipboard(usage(variable.key))"
>
<IconCopy class="size-3.5" />
{{ usage(variable.key) }}
</button>
</div>
<Button variant="outline" size="sm" class="self-start" @click="addVariable">
<IconPlus class="size-4" />
{{ $t('automations.variables.add') }}
</Button>
</div>
</template>

View file

@ -1,21 +1,20 @@
<script setup lang="ts">
import { usePage } from '@inertiajs/vue3';
import { IconCheck } from '@tabler/icons-vue';
import { computed, ref, watch } from 'vue';
import ChannelConfigurator from '@/components/ChannelConfigurator.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 { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { ContentType } from '@/types/content-type';
import { Platform } from '@/types/platform';
import { getMediaRulesForContentType } from '@/composables/useMediaRules';
import { getMediaIncompatibilityReason, getPlatformMetaIssue } from '@/composables/usePostCompliance';
import type { PinterestBoard } from '@/types';
import type { Channel } from '@/types/channel';
import { ContentType } from '@/types/content-type';
import type { MediaItem } from '@/types/media';
import { Platform } from '@/types/platform';
interface SocialAccount {
id: string;
@ -143,11 +142,15 @@ const local = ref<GenerateConfig>({
watch(local, (val) => emit('update', val), { deep: true });
const isSelected = (accountId: string): boolean =>
local.value.accounts.some((a) => a.social_account_id === accountId);
const selectedAccountIds = computed(() => local.value.accounts.map((a) => a.social_account_id));
const onToggleAccount = (accountId: string) => {
const account = accountById(accountId);
if (account) toggleAccount(account);
};
const toggleAccount = (account: SocialAccount) => {
if (isSelected(account.id)) {
if (selectedAccountIds.value.includes(account.id)) {
local.value.accounts = local.value.accounts.filter((a) => a.social_account_id !== account.id);
return;
}
@ -161,15 +164,6 @@ const toggleAccount = (account: SocialAccount) => {
];
};
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;
@ -191,18 +185,83 @@ const getCreatorInfo = (account: SocialAccount): TikTokCreatorInfo | null =>
const getBoards = (account: SocialAccount): PinterestBoard[] =>
pinterestBoards.value[account.id] ?? [];
// Carousel-capable content types: instagram_feed, linkedin_carousel,
// linkedin_page_carousel, pinterest_carousel, tiktok_photo.
const carouselCapableContentTypes = new Set([
ContentType.InstagramFeed,
ContentType.LinkedInCarousel,
ContentType.LinkedInPageCarousel,
ContentType.PinterestCarousel,
ContentType.TikTokPhoto,
]);
// Image-capability is derived from the SAME media rules the post editor uses
// (per content type), never a hardcoded list — facebook_post, tiktok_photo,
// linkedin_carousel etc. all accept multiple images. We cap AI image generation
// at MAX_GENERATED_IMAGES regardless of how many a platform technically allows.
const MAX_GENERATED_IMAGES = 10;
const hasCarouselCapableAccount = computed(() =>
local.value.accounts.some((a) => carouselCapableContentTypes.has(a.content_type as ContentType)),
const multiImageAccounts = computed(() =>
local.value.accounts.filter((a) => {
const rules = getMediaRulesForContentType(a.content_type);
return rules.acceptImages && rules.maxFiles > 1;
}),
);
const supportsMultiImage = computed(() => multiImageAccounts.value.length > 0);
const imageCountCap = computed(() => {
if (!supportsMultiImage.value) return 1;
const maxAcrossAccounts = Math.max(
...multiImageAccounts.value.map((a) => getMediaRulesForContentType(a.content_type).maxFiles),
);
return Math.min(MAX_GENERATED_IMAGES, maxAcrossAccounts);
});
const imageCountOptions = computed(() =>
Array.from({ length: Math.max(0, imageCountCap.value - 1) }, (_, i) => i + 2),
);
// How many images each selected account will receive — the carousel slide count
// when any account supports multiple, otherwise a single image when enabled.
const intendedImageCount = computed(() =>
supportsMultiImage.value
? local.value.target_slide_count
: (local.value.include_image ? 1 : 0),
);
const syntheticImages = (count: number): MediaItem[] =>
Array.from({ length: Math.max(0, count) }, () => ({ type: 'image' }) as MediaItem);
// Reuses the post editor's exact compliance: per-content-type media rules
// (too many / too few images, image-not-supported, media-required) AND the
// platform meta rules (TikTok privacy/disclosure, Pinterest board required).
const accountIssue = (accountId: string): string | null => {
const entry = local.value.accounts.find((a) => a.social_account_id === accountId);
if (!entry) return null;
const mediaIssue = getMediaIncompatibilityReason(entry.content_type, syntheticImages(intendedImageCount.value));
if (mediaIssue) return mediaIssue;
const account = accountById(accountId);
return account ? getPlatformMetaIssue(account.platform, entry.meta) : null;
};
watch(imageCountCap, (cap) => {
if (local.value.target_slide_count > cap) {
local.value.target_slide_count = cap;
}
if (local.value.target_slide_count < 2) {
local.value.target_slide_count = Math.min(2, cap);
}
});
const channels = computed<Channel[]>(() =>
socialAccounts.value.map((account) => {
const entry = local.value.accounts.find((a) => a.social_account_id === account.id);
return {
id: account.id,
platform: account.platform,
displayName: account.display_name,
username: account.username,
avatarUrl: account.avatar_url,
socialAccount: account,
contentType: entry?.content_type ?? defaultContentTypeFor(account.platform),
meta: entry?.meta ?? {},
issue: accountIssue(account.id),
publishConfig: getPublishConfig(account),
creatorInfo: getCreatorInfo(account),
boards: getBoards(account),
};
}),
);
</script>
@ -214,99 +273,22 @@ const hasCarouselCapableAccount = computed(() =>
<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>
<ChannelConfigurator
v-else
:channels="channels"
:selected-ids="selectedAccountIds"
:preview-only="true"
@toggle="onToggleAccount"
@update:content-type="updateContentType"
@update:meta="updateMeta"
/>
</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" class="space-y-2">
<div v-if="supportsMultiImage" class="space-y-2">
<Label class="text-sm font-bold">{{ $t('automations.config.generate.target_slide_count') }}</Label>
<div class="flex flex-wrap gap-2">
<Button
v-for="n in [2, 3, 4, 5, 6, 7, 8, 9, 10]"
v-for="n in imageCountOptions"
:key="n"
type="button"
size="icon"

View file

@ -20,11 +20,11 @@ const summary = computed(() => {
<template>
<div
class="automation-node automation-node--accent-amber"
class="automation-node automation-node--accent-cyan"
:class="{ 'is-selected': selected }"
>
<div class="automation-node__header">
<div class="automation-node__icon-tile automation-node__icon-tile--amber">
<div class="automation-node__icon-tile automation-node__icon-tile--cyan">
<IconClock :size="16" />
</div>
<span class="automation-node__title">{{ $t('automations.nodes.delay') }}</span>
@ -35,12 +35,12 @@ const summary = computed(() => {
<Handle
type="target"
:position="Position.Left"
class="!bg-amber-500"
class="!bg-cyan-500"
/>
<Handle
type="source"
:position="Position.Right"
class="!bg-amber-500"
class="!bg-cyan-500"
/>
</div>
</template>

View file

@ -17,8 +17,8 @@ import {
import { getMediaValidationWarning } from '@/composables/useMedia';
import { usePageErrors } from '@/composables/usePageErrors';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import { ContentType } from '@/types/content-type';
import type { PinterestBoard } from '@/types';
import { ContentType } from '@/types/content-type';
import type { MediaItem } from '@/types/media';
interface SocialAccount {

View file

@ -1,20 +1,14 @@
<script setup lang="ts">
import { IconAlertCircle, IconCircleCheck, IconExternalLink, IconLoader2 } from '@tabler/icons-vue';
import { IconExternalLink, IconLoader2 } from '@tabler/icons-vue';
import { computed } from 'vue';
import ChannelConfigurator from '@/components/ChannelConfigurator.vue';
import LabelBadge from '@/components/labels/LabelBadge.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 { Avatar } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { usePageErrors } from '@/composables/usePageErrors';
import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
import { Platform } from '@/types/platform';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import type { PinterestBoard } from '@/types';
import type { Channel } from '@/types/channel';
import type { MediaItem } from '@/types/media';
import { PostPlatformStatus } from '@/types/post';
@ -93,38 +87,6 @@ const emit = defineEmits<{
'update:platformContentType': [platformId: string, contentType: string];
}>();
const selectedTikTokPlatforms = computed(() =>
props.postPlatforms.filter(
(pp) => pp.platform === Platform.TikTok && props.selectedPlatformIds.includes(pp.id),
),
);
const selectedInstagramPlatforms = computed(() =>
props.postPlatforms.filter(
(pp) => (pp.platform === Platform.Instagram || pp.platform === Platform.InstagramFacebook)
&& props.selectedPlatformIds.includes(pp.id),
),
);
const selectedFacebookPlatforms = computed(() =>
props.postPlatforms.filter(
(pp) => pp.platform === Platform.Facebook && props.selectedPlatformIds.includes(pp.id),
),
);
const selectedLinkedInPlatforms = computed(() =>
props.postPlatforms.filter(
(pp) => (pp.platform === Platform.LinkedIn || pp.platform === Platform.LinkedInPage)
&& props.selectedPlatformIds.includes(pp.id),
),
);
const selectedPinterestPlatforms = computed(() =>
props.postPlatforms.filter(
(pp) => pp.platform === Platform.Pinterest && props.selectedPlatformIds.includes(pp.id),
),
);
const getPublishConfig = (pp: PostPlatform): Record<string, any> | null =>
pp.social_account_id ? props.platformConfigs[pp.social_account_id]?.publishConfig ?? null : null;
@ -164,6 +126,24 @@ const contentTypeErrorFor = (pp: PostPlatform): string | undefined => {
return errors.value[`platforms.${index}.content_type`];
};
const channels = computed<Channel[]>(() =>
props.postPlatforms.map((pp) => ({
id: pp.id,
platform: pp.platform,
displayName: getPlatformDisplayName(pp),
username: pp.social_account?.username ?? pp.platform_username ?? null,
avatarUrl: getPlatformAvatar(pp),
socialAccount: pp.social_account,
contentType: props.platformContentTypes[pp.id] ?? pp.content_type ?? '',
meta: props.platformMeta[pp.id] ?? {},
issue: props.platformIssues?.[pp.id] ?? null,
status: pp.status,
contentTypeError: contentTypeErrorFor(pp),
publishConfig: getPublishConfig(pp),
creatorInfo: getCreatorInfo(pp),
boards: getBoards(pp),
})),
);
</script>
<template>
@ -172,182 +152,53 @@ const contentTypeErrorFor = (pp: PostPlatform): string | undefined => {
<p class="mb-3 text-[11px] font-black uppercase tracking-widest text-foreground/60">
{{ $t('posts.edit.publish_to') }}
</p>
<div class="flex flex-wrap gap-3">
<TooltipProvider v-for="pp in postPlatforms" :key="pp.id" :delay-duration="200">
<Tooltip>
<TooltipTrigger as-child>
<button
type="button"
class="flex w-20 cursor-pointer flex-col items-center gap-1.5 transition-opacity"
:class="[
platformIssues?.[pp.id] && !selectedPlatformIds.includes(pp.id) ? 'cursor-not-allowed opacity-40' : '',
platformIssues?.[pp.id] && selectedPlatformIds.includes(pp.id) ? 'opacity-100' : '',
!platformIssues?.[pp.id] ? 'opacity-100 hover:opacity-90' : '',
]"
:disabled="Boolean(platformIssues?.[pp.id]) && !selectedPlatformIds.includes(pp.id)"
@click="emit('togglePlatform', pp.id)"
>
<div class="relative">
<Avatar
:src="getPlatformAvatar(pp)"
:name="getPlatformDisplayName(pp)"
class="size-10 shrink-0 rounded-full border-2"
:class="[
platformIssues?.[pp.id] && selectedPlatformIds.includes(pp.id) ? 'border-rose-500 shadow-2xs' : '',
!platformIssues?.[pp.id] && selectedPlatformIds.includes(pp.id) ? 'border-foreground shadow-2xs' : '',
!selectedPlatformIds.includes(pp.id) ? 'border-foreground/20' : '',
]"
/>
<span class="absolute -bottom-1 -right-1 inline-flex size-5 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card shadow-2xs">
<img
:src="getPlatformLogo(pp.platform)"
:alt="pp.platform"
class="size-full object-cover"
/>
</span>
<Badge
v-if="platformIssues?.[pp.id] && selectedPlatformIds.includes(pp.id)"
variant="destructive"
class="absolute -top-1 -right-1 h-4 w-4 p-0"
>
<IconAlertCircle class="h-2.5 w-2.5" />
</Badge>
<Badge v-else-if="pp.status === PostPlatformStatus.Published" variant="success" class="absolute -top-1 -right-1 h-4 w-4 p-0">
<IconCircleCheck class="h-2.5 w-2.5" />
</Badge>
<Badge v-else-if="pp.status === PostPlatformStatus.Failed" variant="destructive" class="absolute -top-1 -right-1 h-4 w-4 p-0 text-[9px]">!</Badge>
</div>
<span
class="line-clamp-2 text-center text-xs leading-tight"
:class="selectedPlatformIds.includes(pp.id) ? 'font-bold text-foreground' : 'font-medium text-foreground/70'"
>
{{ getPlatformDisplayName(pp) }}
</span>
</button>
</TooltipTrigger>
<TooltipContent>
<div class="space-y-0.5 text-xs">
<p class="font-semibold">{{ getPlatformDisplayName(pp) }}<span v-if="pp.social_account?.username" class="font-normal opacity-80">&nbsp;·&nbsp;@{{ pp.social_account.username }}</span></p>
<p class="opacity-70">{{ getPlatformLabel(pp.platform) }}</p>
<p v-if="platformIssues?.[pp.id]" class="mt-1 max-w-xs text-destructive-foreground/90">
{{ platformIssues[pp.id] }}
</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<div v-if="postPlatforms.some(pp => pp.status !== PostPlatformStatus.Pending)">
<p class="mb-2 text-[11px] font-black uppercase tracking-widest text-foreground/60">
{{ $t('posts.edit.platform_status') }}
</p>
<div class="space-y-2">
<div
v-for="pp in postPlatforms.filter(p => p.enabled)"
:key="pp.id"
class="flex items-center justify-between rounded-xl border-2 border-foreground bg-card p-3 shadow-2xs"
>
<div class="flex items-center gap-2">
<span class="inline-flex size-5 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card">
<img :src="getPlatformLogo(pp.platform)" :alt="pp.platform" class="size-full object-cover" />
</span>
<span class="text-sm font-bold text-foreground">{{ getPlatformDisplayName(pp) }}</span>
</div>
<div class="flex items-center gap-2">
<Badge v-if="pp.status === PostPlatformStatus.Published" variant="success">{{ $t('posts.edit.status.published') }}</Badge>
<Badge v-else-if="pp.status === PostPlatformStatus.Publishing" variant="warning">
<IconLoader2 class="size-3 animate-spin" />
{{ $t('posts.edit.status.publishing') }}
</Badge>
<Badge v-else-if="pp.status === PostPlatformStatus.Failed" variant="destructive">{{ $t('posts.edit.status.failed') }}</Badge>
<a
v-if="pp.platform_url"
:href="pp.platform_url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex size-7 items-center justify-center rounded-full border-2 border-foreground bg-card text-foreground shadow-2xs transition-transform hover:rotate-3 hover:bg-violet-100"
<ChannelConfigurator
:channels="channels"
:selected-ids="selectedPlatformIds"
:media="media ?? []"
:video-duration-sec="videoDurationSec"
:disabled="isReadOnly"
@toggle="(id: string) => emit('togglePlatform', id)"
@update:content-type="(id: string, value: string) => emit('update:platformContentType', id, value)"
@update:meta="(id: string, value: Record<string, any>) => emit('update:platformMeta', id, value)"
>
<div v-if="postPlatforms.some(pp => pp.status !== PostPlatformStatus.Pending)">
<p class="mb-2 text-[11px] font-black uppercase tracking-widest text-foreground/60">
{{ $t('posts.edit.platform_status') }}
</p>
<div class="space-y-2">
<div
v-for="pp in postPlatforms.filter(p => p.enabled)"
:key="pp.id"
class="flex items-center justify-between rounded-xl border-2 border-foreground bg-card p-3 shadow-2xs"
>
<IconExternalLink class="size-3.5" stroke-width="2.5" />
</a>
<div class="flex items-center gap-2">
<span class="inline-flex size-5 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card">
<img :src="getPlatformLogo(pp.platform)" :alt="pp.platform" class="size-full object-cover" />
</span>
<span class="text-sm font-bold text-foreground">{{ getPlatformDisplayName(pp) }}</span>
</div>
<div class="flex items-center gap-2">
<Badge v-if="pp.status === PostPlatformStatus.Published" variant="success">{{ $t('posts.edit.status.published') }}</Badge>
<Badge v-else-if="pp.status === PostPlatformStatus.Publishing" variant="warning">
<IconLoader2 class="size-3 animate-spin" />
{{ $t('posts.edit.status.publishing') }}
</Badge>
<Badge v-else-if="pp.status === PostPlatformStatus.Failed" variant="destructive">{{ $t('posts.edit.status.failed') }}</Badge>
<a
v-if="pp.platform_url"
:href="pp.platform_url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex size-7 items-center justify-center rounded-full border-2 border-foreground bg-card text-foreground shadow-2xs transition-transform hover:rotate-3 hover:bg-violet-100"
>
<IconExternalLink class="size-3.5" stroke-width="2.5" />
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Platform-specific settings -->
<div v-if="selectedTikTokPlatforms.length > 0" class="space-y-4">
<TikTokSettings
v-for="pp in selectedTikTokPlatforms"
:key="pp.id"
:social-account="pp.social_account"
:publish-config="getPublishConfig(pp)"
:creator-info="getCreatorInfo(pp)"
:video-duration-sec="videoDurationSec"
:content-type="platformContentTypes[pp.id] ?? ''"
:content-type-error="contentTypeErrorFor(pp)"
:meta="platformMeta[pp.id] ?? {}"
:disabled="isReadOnly"
@update:content-type="emit('update:platformContentType', pp.id, $event)"
@update:meta="emit('update:platformMeta', pp.id, $event)"
/>
</div>
<div v-if="selectedInstagramPlatforms.length > 0" class="space-y-4">
<InstagramSettings
v-for="pp in selectedInstagramPlatforms"
:key="pp.id"
:social-account="pp.social_account"
:content-type="platformContentTypes[pp.id] ?? ''"
:media="media ?? []"
:meta="platformMeta[pp.id] ?? {}"
:disabled="isReadOnly"
@update:content-type="emit('update:platformContentType', pp.id, $event)"
@update:meta="emit('update:platformMeta', pp.id, $event)"
/>
</div>
<div v-if="selectedFacebookPlatforms.length > 0" class="space-y-4">
<FacebookSettings
v-for="pp in selectedFacebookPlatforms"
:key="pp.id"
:social-account="pp.social_account"
:content-type="platformContentTypes[pp.id] ?? ''"
:media="media ?? []"
:meta="platformMeta[pp.id] ?? {}"
:disabled="isReadOnly"
@update:content-type="emit('update:platformContentType', pp.id, $event)"
@update:meta="emit('update:platformMeta', pp.id, $event)"
/>
</div>
<div v-if="selectedLinkedInPlatforms.length > 0" class="space-y-4">
<LinkedInSettings
v-for="pp in selectedLinkedInPlatforms"
:key="pp.id"
:social-account="pp.social_account"
:platform="pp.platform"
:content-type="platformContentTypes[pp.id] ?? ''"
:media="media ?? []"
:disabled="isReadOnly"
@update:content-type="emit('update:platformContentType', pp.id, $event)"
/>
</div>
<div v-if="selectedPinterestPlatforms.length > 0" class="space-y-4">
<PinterestSettings
v-for="pp in selectedPinterestPlatforms"
:key="pp.id"
:social-account="pp.social_account"
:content-type="platformContentTypes[pp.id] ?? ''"
:media="media ?? []"
:boards="getBoards(pp)"
:meta="platformMeta[pp.id] ?? {}"
:disabled="isReadOnly"
@update:content-type="emit('update:platformContentType', pp.id, $event)"
@update:meta="emit('update:platformMeta', pp.id, $event)"
/>
</ChannelConfigurator>
</div>
<div>

View file

@ -5,8 +5,8 @@ import { getMediaItemIssue } from '@/composables/useMedia';
import { getMediaRulesForContentType } from '@/composables/useMediaRules';
import { getPlatformLabel } from '@/composables/usePlatformLogo';
import { ContentType } from '@/types/content-type';
import { Platform } from '@/types/platform';
import type { MediaItem } from '@/types/media';
import { Platform } from '@/types/platform';
export interface CompliancePostPlatform {
id: string;
@ -42,9 +42,15 @@ const PLATFORM_META_RULES: Record<string, MetaRule> = {
&& !meta.brand_organic_toggle
&& !meta.brand_content_toggle;
const privacyLevelMissing = !meta.privacy_level;
let tooltipKey: string | null = null;
if (disclosureIncomplete) {
tooltipKey = 'posts.form.tiktok.compliance_incomplete';
} else if (privacyLevelMissing) {
tooltipKey = 'posts.form.tiktok.privacy_required';
}
return {
valid: !disclosureIncomplete && !privacyLevelMissing,
tooltipKey: disclosureIncomplete ? 'posts.form.tiktok.compliance_incomplete' : null,
tooltipKey,
};
},
[Platform.Pinterest]: (meta) => ({
@ -53,6 +59,29 @@ const PLATFORM_META_RULES: Record<string, MetaRule> = {
}),
};
/**
* Evaluates a platform's publish-time meta requirements. Single source of truth
* shared by the post editor's compliance gate and the automation Generate node.
*/
export const evaluatePlatformMeta = (
platform: string,
meta: Record<string, any>,
): { valid: boolean; tooltipKey: string | null } => {
const rule = PLATFORM_META_RULES[platform];
if (!rule) return { valid: true, tooltipKey: null };
return rule(meta ?? {});
};
/**
* Translated meta issue for a platform (or null when compliant) — the same
* requirement the post editor enforces before scheduling.
*/
export const getPlatformMetaIssue = (platform: string, meta: Record<string, any>): string | null => {
const result = evaluatePlatformMeta(platform, meta);
if (result.valid) return null;
return result.tooltipKey ? trans(result.tooltipKey) : trans('posts.edit.compliance_incomplete');
};
export const getMediaIncompatibilityReason = (
contentType: string,
mediaItems: MediaItem[],
@ -177,11 +206,9 @@ export const usePostCompliance = (opts: UsePostComplianceOptions) => {
return issues;
});
const platformMetaResults = computed(() => selectedPlatforms.value.map((pp) => {
const rule = PLATFORM_META_RULES[pp.platform];
if (!rule) return { valid: true, tooltipKey: null };
return rule(platformMeta.value[pp.id] ?? {});
}));
const platformMetaResults = computed(() => selectedPlatforms.value.map(
(pp) => evaluatePlatformMeta(pp.platform, platformMeta.value[pp.id] ?? {}),
));
const hasContentOrMedia = computed(
() => content.value.trim().length > 0 || media.value.length > 0,

View file

@ -1,5 +1,6 @@
import type { InertiaLinkProps } from '@inertiajs/vue3';
import { clsx, type ClassValue } from 'clsx';
import { trans } from 'laravel-vue-i18n';
import { twMerge } from 'tailwind-merge';
import { toast } from 'vue-sonner';
@ -44,14 +45,11 @@ export const formatMoneyCompact = (cents: number): string => {
}).format(dollars);
};
export const copyToClipboard = async (
text: string,
message = 'Copied to clipboard',
) => {
export const copyToClipboard = async (text: string, message?: string) => {
try {
await navigator.clipboard.writeText(text);
toast.success(message);
toast.success(message ?? trans('common.actions.copied'));
} catch {
toast.error('Failed to copy to clipboard');
toast.error(trans('common.actions.copy_failed'));
}
};

View file

@ -1,21 +1,8 @@
<script setup lang="ts">
import { Head, Link, router } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { toast } from 'vue-sonner';
import {
IconBolt,
IconCircleX,
IconClock,
IconGitBranch,
IconRss,
IconSend,
IconSparkles,
IconTrash,
IconWebhook,
IconWorld,
IconX,
} from '@tabler/icons-vue';
import { computed, markRaw, ref, watch } from 'vue';
import { IconBolt } from '@tabler/icons-vue';
import { Background } from '@vue-flow/background';
import { Controls } from '@vue-flow/controls';
import {
ConnectionMode,
MarkerType,
@ -26,38 +13,16 @@ import {
type Node,
type XYPosition,
} from '@vue-flow/core';
import { Background } from '@vue-flow/background';
import { Controls } from '@vue-flow/controls';
import { trans } from 'laravel-vue-i18n';
import { computed, markRaw, ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import '@vue-flow/core/dist/style.css';
import '@vue-flow/core/dist/theme-default.css';
import '@vue-flow/controls/dist/style.css';
import { Button } from '@/components/ui/button';
import AppLayout from '@/layouts/AppLayout.vue';
import {
show as showAutomation,
update as updateAutomation,
} from '@/routes/app/automations';
import AutomationConnectionLine from '@/components/automations/AutomationConnectionLine.vue';
import TestRunPanel from '@/components/automations/TestRunPanel.vue';
import { useHistory } from '@/composables/history/useHistory';
import { AddEdgeCommand } from '@/composables/history/commands/AddEdgeCommand';
import { AddNodeCommand } from '@/composables/history/commands/AddNodeCommand';
import { MoveNodeCommand } from '@/composables/history/commands/MoveNodeCommand';
import { RemoveEdgeCommand } from '@/composables/history/commands/RemoveEdgeCommand';
import { RemoveNodeCommand } from '@/composables/history/commands/RemoveNodeCommand';
import { UpdateNodeDataCommand } from '@/composables/history/commands/UpdateNodeDataCommand';
import { useShortcut } from '@/composables/useShortcut';
import { usePageErrors } from '@/composables/usePageErrors';
import type { Automation } from '@/types/automation/automation';
import { NodeType } from '@/types/automation/node-type';
import type { RawConnection } from '@/types/automation/raw-connection';
import { ScheduleField } from '@/types/automation/schedule-field';
import { TriggerType } from '@/types/automation/trigger-type';
import ConditionNode from '@/components/automations/nodes/ConditionNode.vue';
import DelayNode from '@/components/automations/nodes/DelayNode.vue';
import EndNode from '@/components/automations/nodes/EndNode.vue';
import HttpRequestNode from '@/components/automations/nodes/HttpRequestNode.vue';
import FetchRssNode from '@/components/automations/nodes/FetchRssNode.vue';
@ -75,6 +40,29 @@ import GenerateNodeConfig from '@/components/automations/config/GenerateNodeConf
import PublishNodeConfig from '@/components/automations/config/PublishNodeConfig.vue';
import TriggerNodeConfig from '@/components/automations/config/TriggerNodeConfig.vue';
import WebhookNodeConfig from '@/components/automations/config/WebhookNodeConfig.vue';
import EditorSidebar from '@/components/automations/EditorSidebar.vue';
import ConditionNode from '@/components/automations/nodes/ConditionNode.vue';
import DelayNode from '@/components/automations/nodes/DelayNode.vue';
import { Button } from '@/components/ui/button';
import { AddEdgeCommand } from '@/composables/history/commands/AddEdgeCommand';
import { AddNodeCommand } from '@/composables/history/commands/AddNodeCommand';
import { MoveNodeCommand } from '@/composables/history/commands/MoveNodeCommand';
import { RemoveEdgeCommand } from '@/composables/history/commands/RemoveEdgeCommand';
import { RemoveNodeCommand } from '@/composables/history/commands/RemoveNodeCommand';
import { UpdateNodeDataCommand } from '@/composables/history/commands/UpdateNodeDataCommand';
import { useHistory } from '@/composables/history/useHistory';
import { usePageErrors } from '@/composables/usePageErrors';
import { useShortcut } from '@/composables/useShortcut';
import AppLayout from '@/layouts/AppLayout.vue';
import {
show as showAutomation,
update as updateAutomation,
} from '@/routes/app/automations';
import type { Automation, AutomationVariable } from '@/types/automation/automation';
import { NodeType } from '@/types/automation/node-type';
import type { RawConnection } from '@/types/automation/raw-connection';
import { ScheduleField } from '@/types/automation/schedule-field';
import { TriggerType } from '@/types/automation/trigger-type';
const props = defineProps<{ automation: Automation }>();
@ -102,28 +90,6 @@ const configByType: Record<string, unknown> = {
[NodeType.HttpRequest]: HttpRequestNodeConfig,
};
const nodeTypeOptions = computed(() => [
{ type: NodeType.Trigger, label: trans('automations.nodes.trigger'), icon: IconBolt, accent: 'violet' },
{ type: NodeType.FetchRss, label: trans('automations.nodes.fetch_rss'), icon: IconRss, accent: 'amber' },
{ type: NodeType.HttpRequest, label: trans('automations.nodes.http_request'), icon: IconWorld, accent: 'slate' },
{ type: NodeType.Generate, label: trans('automations.nodes.generate'), icon: IconSparkles, accent: 'blue' },
{ type: NodeType.Delay, label: trans('automations.nodes.delay'), icon: IconClock, accent: 'amber' },
{ type: NodeType.Condition, label: trans('automations.nodes.condition'), icon: IconGitBranch, accent: 'rose' },
{ type: NodeType.Publish, label: trans('automations.nodes.publish'), icon: IconSend, accent: 'emerald' },
{ type: NodeType.Webhook, label: trans('automations.nodes.webhook'), icon: IconWebhook, accent: 'slate' },
{ type: NodeType.End, label: trans('automations.nodes.end'), icon: IconCircleX, accent: 'zinc' },
]);
const accentClasses: Record<string, { dot: string; tint: string; text: string }> = {
violet: { dot: 'bg-violet-500', tint: 'bg-violet-200', text: 'text-violet-900' },
blue: { dot: 'bg-blue-500', tint: 'bg-blue-200', text: 'text-blue-900' },
amber: { dot: 'bg-amber-500', tint: 'bg-amber-200', text: 'text-amber-900' },
rose: { dot: 'bg-rose-500', tint: 'bg-rose-200', text: 'text-rose-900' },
emerald: { dot: 'bg-emerald-500', tint: 'bg-emerald-200', text: 'text-emerald-900' },
slate: { dot: 'bg-slate-500', tint: 'bg-slate-200', text: 'text-slate-900' },
zinc: { dot: 'bg-zinc-500', tint: 'bg-zinc-200', text: 'text-zinc-900' },
};
const hydrateEdges = (list: RawConnection[]): Edge[] =>
list.map((e) => ({
id: e.id,
@ -137,6 +103,7 @@ const nodes = ref<Node[]>(props.automation.nodes ?? []);
const edges = ref<Edge[]>(hydrateEdges(props.automation.connections ?? []));
const selectedNodeId = ref<string | null>(null);
const name = ref(props.automation.name);
const variables = ref<AutomationVariable[]>(props.automation.variables ?? []);
watch(
() => [props.automation.nodes, props.automation.connections] as const,
@ -154,6 +121,10 @@ watch(() => props.automation.name, (newName) => {
name.value = newName;
});
watch(() => props.automation.variables, (newVariables) => {
variables.value = newVariables ?? [];
});
const selectedNode = computed(() => nodes.value.find((n) => n.id === selectedNodeId.value));
const selectedConfigComponent = computed(() =>
selectedNode.value ? configByType[selectedNode.value.type as string] : null,
@ -189,7 +160,6 @@ const {
} = useVueFlow();
onNodeClick(({ node }) => {
isTestPanelOpen.value = false;
selectedNodeId.value = node.id;
});
@ -301,16 +271,6 @@ const createNodeAt = (type: string, position: { x: number; y: number }) => {
history.push(new AddNodeCommand(node, nodes));
};
const addNodeOfType = (type: string) => {
createNodeAt(type, { x: 200 + Math.random() * 100, y: 100 + Math.random() * 200 });
};
const onDragStart = (event: DragEvent, nodeType: string) => {
if (!event.dataTransfer) return;
event.dataTransfer.setData('application/automation-node-type', nodeType);
event.dataTransfer.effectAllowed = 'move';
};
const onDragOver = (event: DragEvent) => {
event.preventDefault();
if (event.dataTransfer) {
@ -328,6 +288,8 @@ const onDrop = (event: DragEvent) => {
const deleteSelectedNode = () => {
if (!selectedNode.value) return;
// The trigger is the automation's single entry point — it can't be deleted.
if (selectedNode.value.type === NodeType.Trigger) return;
const node = selectedNode.value;
const cascadedEdges = edges.value.filter((e) => e.source === node.id || e.target === node.id);
@ -342,11 +304,7 @@ const deleteSelectedNode = () => {
};
const isSaving = ref(false);
const isTestPanelOpen = ref(false);
const handleTestClick = () => {
isTestPanelOpen.value = true;
};
const activeTab = ref('build');
const sanitizeNodes = (list: Node[]) =>
list.map((n) => ({
@ -378,6 +336,7 @@ const save = (): Promise<boolean> =>
name: name.value.trim() || props.automation.name,
nodes: sanitizeNodes(nodes.value),
connections: sanitizeEdges(edges.value),
variables: variables.value.filter((variable) => variable.key.trim() !== ''),
},
{
preserveScroll: true,
@ -387,8 +346,15 @@ const save = (): Promise<boolean> =>
resolve(true);
},
onError: (errors: Record<string, string>) => {
const msg = (errors as any).message ?? trans('automations.form.save_error_fallback');
toast.error(msg);
// Field errors render inline via the node config's <InputError>,
// so we just reveal the node carrying the first one. Only general
// failures (no field to attach to) fall back to a toast.
const erroredNodeIndex = Object.keys(errors).find((key) => key.startsWith('nodes.'))?.split('.')[1];
if (erroredNodeIndex !== undefined) {
selectedNodeId.value = nodes.value[Number(erroredNodeIndex)]?.id ?? selectedNodeId.value;
} else {
toast.error(errors.message ?? trans('automations.form.save_error_fallback'));
}
resolve(false);
},
},
@ -435,8 +401,8 @@ const defaultEdgeOptions = {
<template>
<Head :title="`Edit ${automation.name}`" />
<AppLayout>
<div class="fixed inset-0 z-50 flex flex-col bg-background">
<AppLayout full-width>
<div class="flex min-h-0 flex-1 flex-col bg-background">
<header class="grid flex-shrink-0 grid-cols-[1fr_auto_1fr] items-center gap-3 border-b-2 border-foreground/10 bg-card px-4 py-2">
<div class="flex items-center">
<Link :href="showAutomation.url(automation.id)">
@ -450,31 +416,11 @@ const defaultEdgeOptions = {
class="w-72 rounded-md border-2 border-transparent bg-transparent px-3 py-1 text-center text-sm font-semibold text-foreground transition-colors hover:border-foreground/15 focus:border-foreground focus:bg-background focus:outline-none"
/>
<div class="flex items-center justify-end gap-2">
<Button variant="outline" size="sm" @click="handleTestClick">{{ $t('automations.actions.test') }}</Button>
<Button size="sm" @click="save" :disabled="isSaving">{{ $t('automations.actions.save') }}</Button>
</div>
</header>
<div class="flex flex-1 overflow-hidden">
<aside class="flex w-56 flex-shrink-0 flex-col gap-2 border-r-2 border-foreground/10 bg-card/30 p-4">
<p class="mb-1 text-[11px] font-black uppercase tracking-widest text-foreground/60">
{{ $t('automations.actions.add_node') }}
</p>
<button
v-for="option in nodeTypeOptions"
:key="option.type"
draggable="true"
class="group flex cursor-grab items-center gap-2.5 rounded-xl border-2 border-foreground bg-card p-2.5 text-left text-sm font-bold text-foreground shadow-[2px_2px_0_var(--foreground)] transition-all hover:-translate-x-px hover:-translate-y-px hover:shadow-[3px_3px_0_var(--foreground)] active:translate-x-0 active:translate-y-0 active:rotate-[-1deg] active:cursor-grabbing active:shadow-[1px_1px_0_var(--foreground)]"
@dragstart="onDragStart($event, option.type)"
@click="addNodeOfType(option.type)"
>
<div :class="['flex size-7 -rotate-3 items-center justify-center rounded-md border-2 border-foreground', accentClasses[option.accent].tint]">
<component :is="option.icon" :size="14" :class="accentClasses[option.accent].text" />
</div>
<span class="flex-1">{{ option.label }}</span>
</button>
</aside>
<main
class="automations-canvas-host relative flex-1"
@drop="onDrop"
@ -515,35 +461,26 @@ const defaultEdgeOptions = {
</div>
</main>
<TestRunPanel
v-if="isTestPanelOpen"
v-model:open="isTestPanelOpen"
<EditorSidebar
v-model:tab="activeTab"
v-model:variables="variables"
:automation-id="automation.id"
:before-run="save"
/>
<aside
v-else-if="selectedNode"
class="flex w-[36rem] flex-shrink-0 flex-col gap-4 overflow-y-auto border-l-2 border-foreground/10 px-4 pt-4 pb-12"
:editing="!!selectedNode"
:node-title="selectedNode ? $t(`automations.nodes.${selectedNode.type}`) : ''"
:deletable="selectedNode?.type !== NodeType.Trigger"
@back="closePanel"
@delete="deleteSelectedNode"
>
<div class="flex items-center justify-between">
<h2 class="font-semibold capitalize">{{ $t('automations.form.config_title', { type: $t(`automations.nodes.${selectedNode.type}`) }) }}</h2>
<div class="flex items-center gap-1">
<Button variant="ghost" size="icon-sm" @click="deleteSelectedNode">
<IconTrash class="size-4" />
</Button>
<Button variant="ghost" size="icon-sm" @click="closePanel">
<IconX class="size-4" />
</Button>
</div>
</div>
<component
:is="selectedConfigComponent"
:data="selectedNode.data"
:errors="selectedNodeErrors"
@update="updateSelectedConfig"
/>
</aside>
<template v-if="selectedNode" #config>
<component
:is="selectedConfigComponent"
:data="selectedNode.data"
:errors="selectedNodeErrors"
@update="updateSelectedConfig"
/>
</template>
</EditorSidebar>
</div>
</div>
</AppLayout>
@ -576,8 +513,8 @@ const defaultEdgeOptions = {
background: var(--card);
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
color: var(--foreground);
width: 28px;
height: 28px;
width: 24px;
height: 24px;
transition: background-color 120ms ease;
}
@ -591,8 +528,8 @@ const defaultEdgeOptions = {
.automations-canvas .vue-flow__controls-button svg {
fill: currentColor;
max-width: 14px;
max-height: 14px;
max-width: 12px;
max-height: 12px;
}
/* Edges — TryPost ink-on-cream. Default uses the foreground color at 55% so

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { Head, InfiniteScroll, Link, router } from '@inertiajs/vue3';
import { Head, InfiniteScroll, router } from '@inertiajs/vue3';
import { IconBolt, IconCircleCheck, IconCircleDot, IconCircleX, IconPlus } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { ref } from 'vue';
@ -20,7 +20,6 @@ import {
import dayjs from '@/dayjs';
import AppLayout from '@/layouts/AppLayout.vue';
import {
edit as editAutomation,
show as showAutomation,
store as storeAutomation,
} from '@/routes/app/automations';
@ -95,7 +94,6 @@ const handleCreate = () => {
<TableHead>{{ $t('automations.index.columns.name') }}</TableHead>
<TableHead>{{ $t('automations.index.columns.status') }}</TableHead>
<TableHead>{{ $t('automations.index.columns.created') }}</TableHead>
<TableHead class="text-right">{{ $t('automations.index.columns.actions') }}</TableHead>
</TableRow>
</TableHeader>
<TableBody id="automations-body">
@ -113,11 +111,6 @@ const handleCreate = () => {
</Badge>
</TableCell>
<TableCell>{{ formatDate(automation.created_at) }}</TableCell>
<TableCell class="text-right" @click.stop>
<Link :href="editAutomation.url(automation.id)">
<Button variant="outline" size="sm">{{ $t('automations.actions.edit') }}</Button>
</Link>
</TableCell>
</TableRow>
</TableBody>
</Table>

View file

@ -2,6 +2,11 @@ import type { Node } from '@vue-flow/core';
import type { RawConnection } from './raw-connection';
export interface AutomationVariable {
key: string;
value: string;
}
/**
* Domain shape of an Automation as it flows between the Inertia backend and
* the Vue pages. Many fields are optional because different pages hydrate
@ -14,6 +19,7 @@ export interface Automation {
status: string;
nodes?: Node[];
connections?: RawConnection[];
variables?: AutomationVariable[];
activated_at?: string | null;
paused_at?: string | null;
created_at?: string;

View file

@ -0,0 +1,43 @@
import type { PinterestBoard } from '@/types';
export interface ChannelAccount {
id: string;
platform: string;
display_name: string;
username: string;
avatar_url: string | null;
}
export interface ChannelTikTokCreatorInfo {
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;
}
/**
* One selectable publishing channel, shared by the post editor's channels tab
* and the automation Generate node. `id` is the selection/update key (a
* post_platform id in the editor, a social account id in automations);
* `socialAccount` is what the per-platform Settings components consume.
*/
export interface Channel {
id: string;
platform: string;
displayName: string;
username: string | null;
avatarUrl: string | null;
socialAccount: ChannelAccount | null;
contentType: string;
meta: Record<string, any>;
issue?: string | null;
status?: string | null;
contentTypeError?: string;
publishConfig?: Record<string, any> | null;
creatorInfo?: ChannelTikTokCreatorInfo | null;
boards?: PinterestBoard[];
}

View file

@ -26,6 +26,18 @@
expect($automation->name)->toBe(__('automations.default_name'));
});
it('seeds exactly one schedule trigger when creating an automation', function () {
$this->actingAs($this->user)
->post(route('app.automations.store'))
->assertRedirect();
$automation = Automation::where('workspace_id', $this->workspace->id)->sole();
expect($automation->nodes)->toHaveCount(1);
expect($automation->nodes[0]['type'])->toBe('trigger');
expect($automation->nodes[0]['data']['trigger_type'])->toBe('schedule');
});
it('updates nodes and connections via PUT', function () {
$automation = Automation::factory()->for($this->workspace)->create();

View file

@ -34,6 +34,30 @@
Queue::assertPushed(ProcessAutomationNode::class);
});
it('claims each run once — a second tick does not re-dispatch an already-claimed run', function () {
Queue::fake();
$automation = Automation::factory()->active()->create([
'nodes' => [
['id' => 't', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => []],
['id' => 'g', 'type' => 'generate', 'position' => ['x' => 1, 'y' => 0], 'data' => []],
],
'connections' => [['id' => 'e1', 'source' => 't', 'target' => 'g']],
]);
$run = AutomationRun::factory()->for($automation)->create([
'status' => Status::Waiting,
'current_node_id' => 't',
'next_action_at' => now()->subMinutes(5),
]);
$this->artisan('automation:process-delays')->assertSuccessful();
$this->artisan('automation:process-delays')->assertSuccessful();
Queue::assertPushed(ProcessAutomationNode::class, 1);
expect($run->fresh()->status)->toBe(Status::Running);
});
it('does not wake runs whose next_action_at is in the future', function () {
Queue::fake();

View file

@ -137,7 +137,7 @@
it('does not advance http watermark or spawn siblings in dry mode', function () {
Http::fake([
'api.example.com/*' => Http::response([
'8.8.8.8/*' => Http::response([
'items' => [
['id' => 'a1', 'published_at' => '2026-01-01T00:00:00Z'],
['id' => 'a2', 'published_at' => '2026-01-02T00:00:00Z'],
@ -160,7 +160,7 @@
]);
$result = app(RunHttpRequestNode::class)($run, [
'url' => 'https://api.example.com/items',
'url' => 'https://8.8.8.8/items',
'method' => 'GET',
'auth_type' => 'none',
'items_path' => 'items',
@ -175,7 +175,7 @@
});
it('still hits the external URL in dry mode regardless of HTTP method', function () {
Http::fake(['api.example.com/*' => Http::response(['ok' => true], 200)]);
Http::fake(['8.8.8.8/*' => Http::response(['ok' => true], 200)]);
$automation = Automation::factory()->for($this->workspace)->create();
$run = AutomationRun::factory()->for($automation)->create([
@ -184,13 +184,13 @@
]);
app(RunHttpRequestNode::class)($run, [
'url' => 'https://api.example.com/notify',
'url' => 'https://8.8.8.8/notify',
'method' => 'POST',
'auth_type' => 'none',
'body_template' => '{"x": 1}',
]);
Http::assertSent(fn ($request) => $request->method() === 'POST' && $request->url() === 'https://api.example.com/notify');
Http::assertSent(fn ($request) => $request->method() === 'POST' && $request->url() === 'https://8.8.8.8/notify');
});
it('does not advance rss watermark or spawn siblings in dry mode', function () {
@ -202,7 +202,7 @@
</channel></rss>
XML;
Http::fake(['feed.example.com/*' => Http::response($rss, 200)]);
Http::fake(['1.1.1.1/*' => Http::response($rss, 200)]);
$automation = Automation::factory()->for($this->workspace)->create();
$run = AutomationRun::factory()->for($automation)->create([
@ -210,7 +210,7 @@
'current_node_id' => 'rss_1',
]);
$result = app(RunFetchRssNode::class)($run, ['feed_url' => 'https://feed.example.com/rss']);
$result = app(RunFetchRssNode::class)($run, ['feed_url' => 'https://1.1.1.1/rss']);
expect($result->status->value)->toBe('completed');
expect($result->output['fetch']['spawned'])->toBe(0);

View file

@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
use App\Actions\Automation\Node\RunGenerateNode;
use App\Enums\UserWorkspace\Role;
use App\Models\Automation;
use App\Models\User;
use App\Models\Workspace;
beforeEach(function () {
$this->workspace = Workspace::factory()->create();
$this->user = User::factory()->create([
'current_workspace_id' => $this->workspace->id,
]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Admin->value]);
$this->user->refresh();
});
it('rejects saving a generate node whose slide count exceeds a content type limit', function () {
$automation = Automation::factory()->for($this->workspace)->create();
$this->actingAs($this->user)
->putJson(route('app.automations.update', $automation->id), [
'nodes' => [
['id' => 'n1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule', 'cron' => '0 9 * * *']],
['id' => 'n2', 'type' => 'generate', 'position' => ['x' => 1, 'y' => 0], 'data' => [
'accounts' => [['social_account_id' => 'acc-1', 'content_type' => 'pinterest_carousel']],
'prompt_template' => 'hi',
'image_source' => 'none',
'target_slide_count' => 8,
]],
],
'connections' => [['id' => 'e1', 'source' => 'n1', 'target' => 'n2']],
])
->assertStatus(422)
->assertJsonValidationErrors(['nodes.1.data.accounts']);
});
it('allows saving a generate node within the content type limit', function () {
$automation = Automation::factory()->for($this->workspace)->create();
$this->actingAs($this->user)
->put(route('app.automations.update', $automation->id), [
'nodes' => [
['id' => 'n1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule', 'cron' => '0 9 * * *']],
['id' => 'n2', 'type' => 'generate', 'position' => ['x' => 1, 'y' => 0], 'data' => [
'accounts' => [['social_account_id' => 'acc-1', 'content_type' => 'pinterest_carousel']],
'prompt_template' => 'hi',
'image_source' => 'none',
'target_slide_count' => 4,
]],
],
'connections' => [['id' => 'e1', 'source' => 'n1', 'target' => 'n2']],
])
->assertRedirect();
expect($automation->fresh()->nodes)->toHaveCount(2);
});
it('saves a generate node using the exact frontend payload (no image_source field)', function () {
$automation = Automation::factory()->for($this->workspace)->create();
$this->actingAs($this->user)
->put(route('app.automations.update', $automation->id), [
'nodes' => [
['id' => 'n1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule', 'cron' => '0 9 * * *']],
['id' => 'n2', 'type' => 'generate', 'position' => ['x' => 1, 'y' => 0], 'data' => [
'accounts' => [['social_account_id' => 'acc-1', 'content_type' => 'instagram_feed', 'meta' => []]],
'prompt_template' => 'hi',
'include_image' => true,
'target_slide_count' => 5,
]],
],
'connections' => [['id' => 'e1', 'source' => 'n1', 'target' => 'n2']],
])
->assertRedirect()
->assertSessionHasNoErrors();
expect($automation->fresh()->nodes)->toHaveCount(2);
});
it('refuses to activate an automation whose generate node requests images for an image-less format', function () {
$automation = Automation::factory()->for($this->workspace)->withScheduleTrigger()->create();
$automation->update([
'nodes' => array_merge($automation->nodes, [
['id' => 'n2', 'type' => 'generate', 'position' => ['x' => 1, 'y' => 1], 'data' => [
'accounts' => [['social_account_id' => 'acc-1', 'content_type' => 'tiktok_video']],
'include_image' => true,
]],
]),
'connections' => [['id' => 'e1', 'source' => 'trigger_1', 'target' => 'n2']],
]);
$this->actingAs($this->user)
->postJson(route('app.automations.activate', $automation->id))
->assertStatus(422);
expect($automation->fresh()->status->value)->not->toBe('active');
});
it('caps the carousel slide count at the global max and the per-content-type limit', function () {
$node = app(RunGenerateNode::class);
// tiktok_photo allows 35, but AI generation is capped at 10.
expect($node->deriveFormat([['content_type' => 'tiktok_photo']], ['target_slide_count' => 15]))
->toBe(['format' => 'carousel', 'slide_count' => 10]);
// pinterest_carousel allows only 5.
expect($node->deriveFormat([['content_type' => 'pinterest_carousel']], ['target_slide_count' => 8]))
->toBe(['format' => 'carousel', 'slide_count' => 5]);
});
it('recognises facebook_post as multi-image capable (rules-derived, not a hardcoded list)', function () {
$node = app(RunGenerateNode::class);
expect($node->deriveFormat([['content_type' => 'facebook_post']], ['target_slide_count' => 6]))
->toBe(['format' => 'carousel', 'slide_count' => 6]);
// A single-image (video) format never becomes a carousel.
expect($node->deriveFormat([['content_type' => 'tiktok_video']], ['target_slide_count' => 5]))
->toBe(['format' => 'single', 'slide_count' => 1]);
});

View file

@ -18,26 +18,26 @@
const FETCH_RSS_OLD = <<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
<item><title>Ancient</title><link>https://blog.example.com/1</link><guid>1</guid><pubDate>Mon, 01 Jan 2024 12:00:00 +0000</pubDate></item>
<item><title>Old</title><link>https://blog.example.com/2</link><guid>2</guid><pubDate>Sat, 01 Feb 2025 12:00:00 +0000</pubDate></item>
<item><title>Ancient</title><link>https://1.1.1.1/1</link><guid>1</guid><pubDate>Mon, 01 Jan 2024 12:00:00 +0000</pubDate></item>
<item><title>Old</title><link>https://1.1.1.1/2</link><guid>2</guid><pubDate>Sat, 01 Feb 2025 12:00:00 +0000</pubDate></item>
</channel></rss>
XML;
const FETCH_RSS_MIXED = <<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
<item><title>Older</title><link>https://blog.example.com/a</link><guid>a</guid><pubDate>Sat, 01 Feb 2025 12:00:00 +0000</pubDate></item>
<item><title>Newer1</title><link>https://blog.example.com/b</link><guid>b</guid><pubDate>Sun, 01 Jun 2025 12:00:00 +0000</pubDate></item>
<item><title>Newer2</title><link>https://blog.example.com/c</link><guid>c</guid><pubDate>Mon, 15 Jun 2025 12:00:00 +0000</pubDate></item>
<item><title>Older</title><link>https://1.1.1.1/a</link><guid>a</guid><pubDate>Sat, 01 Feb 2025 12:00:00 +0000</pubDate></item>
<item><title>Newer1</title><link>https://1.1.1.1/b</link><guid>b</guid><pubDate>Sun, 01 Jun 2025 12:00:00 +0000</pubDate></item>
<item><title>Newer2</title><link>https://1.1.1.1/c</link><guid>c</guid><pubDate>Mon, 15 Jun 2025 12:00:00 +0000</pubDate></item>
</channel></rss>
XML;
const FETCH_RSS_THREE_NEW = <<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
<item><title>New1</title><link>https://blog.example.com/x</link><guid>x</guid><pubDate>Sun, 01 Jun 2025 12:00:00 +0000</pubDate></item>
<item><title>New2</title><link>https://blog.example.com/y</link><guid>y</guid><pubDate>Mon, 02 Jun 2025 12:00:00 +0000</pubDate></item>
<item><title>New3</title><link>https://blog.example.com/z</link><guid>z</guid><pubDate>Tue, 03 Jun 2025 12:00:00 +0000</pubDate></item>
<item><title>New1</title><link>https://1.1.1.1/x</link><guid>x</guid><pubDate>Sun, 01 Jun 2025 12:00:00 +0000</pubDate></item>
<item><title>New2</title><link>https://1.1.1.1/y</link><guid>y</guid><pubDate>Mon, 02 Jun 2025 12:00:00 +0000</pubDate></item>
<item><title>New3</title><link>https://1.1.1.1/z</link><guid>z</guid><pubDate>Tue, 03 Jun 2025 12:00:00 +0000</pubDate></item>
</channel></rss>
XML;
@ -45,12 +45,12 @@
it('first execution dispatches nothing and stores watermark from newest item', function () {
Carbon::setTestNow('2026-01-15 10:00:00');
Http::fake(['blog.example.com/*' => Http::response(FETCH_RSS_OLD, 200)]);
Http::fake(['1.1.1.1/*' => Http::response(FETCH_RSS_OLD, 200)]);
$automation = Automation::factory()->active()->create([
'nodes' => [
['id' => 'trigger_1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule']],
['id' => 'fetch_1', 'type' => 'fetch_rss', 'position' => ['x' => 200, 'y' => 0], 'data' => ['feed_url' => 'https://blog.example.com/feed']],
['id' => 'fetch_1', 'type' => 'fetch_rss', 'position' => ['x' => 200, 'y' => 0], 'data' => ['feed_url' => 'https://1.1.1.1/feed']],
['id' => 'end_1', 'type' => 'end', 'position' => ['x' => 400, 'y' => 0], 'data' => []],
],
'connections' => [
@ -61,7 +61,7 @@
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'fetch_1']);
$result = app(RunFetchRssNode::class)($run, ['feed_url' => 'https://blog.example.com/feed']);
$result = app(RunFetchRssNode::class)($run, ['feed_url' => 'https://1.1.1.1/feed']);
expect($result->status)->toBe(NodeRunStatus::Completed);
expect($result->nextHandle)->toBe('no_items');
@ -74,12 +74,12 @@
it('processes the first new item on the current run and spawns siblings for the rest', function () {
Carbon::setTestNow('2026-01-15 10:00:00');
Http::fake(['blog.example.com/*' => Http::response(FETCH_RSS_MIXED, 200)]);
Http::fake(['1.1.1.1/*' => Http::response(FETCH_RSS_MIXED, 200)]);
$automation = Automation::factory()->active()->create([
'nodes' => [
['id' => 'trigger_1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule']],
['id' => 'fetch_1', 'type' => 'fetch_rss', 'position' => ['x' => 200, 'y' => 0], 'data' => ['feed_url' => 'https://blog.example.com/feed']],
['id' => 'fetch_1', 'type' => 'fetch_rss', 'position' => ['x' => 200, 'y' => 0], 'data' => ['feed_url' => 'https://1.1.1.1/feed']],
['id' => 'end_1', 'type' => 'end', 'position' => ['x' => 400, 'y' => 0], 'data' => []],
],
'connections' => [
@ -97,7 +97,7 @@
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'fetch_1']);
$result = app(RunFetchRssNode::class)($run, ['feed_url' => 'https://blog.example.com/feed']);
$result = app(RunFetchRssNode::class)($run, ['feed_url' => 'https://1.1.1.1/feed']);
expect($result->status)->toBe(NodeRunStatus::Completed);
expect($result->output['fetched']['key'])->toBe('b'); // oldest of the new items
@ -111,12 +111,12 @@
it('spawns sibling runs down the item edge for each remaining new item', function () {
Carbon::setTestNow('2026-01-15 10:00:00');
Http::fake(['blog.example.com/*' => Http::response(FETCH_RSS_THREE_NEW, 200)]);
Http::fake(['1.1.1.1/*' => Http::response(FETCH_RSS_THREE_NEW, 200)]);
$automation = Automation::factory()->active()->create([
'nodes' => [
['id' => 'trigger_1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule']],
['id' => 'fetch_1', 'type' => 'fetch_rss', 'position' => ['x' => 200, 'y' => 0], 'data' => ['feed_url' => 'https://blog.example.com/feed']],
['id' => 'fetch_1', 'type' => 'fetch_rss', 'position' => ['x' => 200, 'y' => 0], 'data' => ['feed_url' => 'https://1.1.1.1/feed']],
['id' => 'generate_1', 'type' => 'generate', 'position' => ['x' => 400, 'y' => 0], 'data' => []],
],
'connections' => [
@ -134,7 +134,7 @@
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'fetch_1']);
$result = app(RunFetchRssNode::class)($run, ['feed_url' => 'https://blog.example.com/feed']);
$result = app(RunFetchRssNode::class)($run, ['feed_url' => 'https://1.1.1.1/feed']);
// Current run handles item #1 (oldest); siblings handle #2 and #3.
expect($result->status)->toBe(NodeRunStatus::Completed);
@ -151,12 +151,12 @@
it('does not persist the production watermark on a manual real-data test', function () {
Carbon::setTestNow('2026-01-15 10:00:00');
Http::fake(['blog.example.com/*' => Http::response(FETCH_RSS_MIXED, 200)]);
Http::fake(['1.1.1.1/*' => Http::response(FETCH_RSS_MIXED, 200)]);
$automation = Automation::factory()->active()->create([
'nodes' => [
['id' => 'trigger_1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule']],
['id' => 'fetch_1', 'type' => 'fetch_rss', 'position' => ['x' => 200, 'y' => 0], 'data' => ['feed_url' => 'https://blog.example.com/feed']],
['id' => 'fetch_1', 'type' => 'fetch_rss', 'position' => ['x' => 200, 'y' => 0], 'data' => ['feed_url' => 'https://1.1.1.1/feed']],
['id' => 'end_1', 'type' => 'end', 'position' => ['x' => 400, 'y' => 0], 'data' => []],
],
'connections' => [
@ -177,7 +177,7 @@
'is_dry_run' => false,
]);
$result = app(RunFetchRssNode::class)($run, ['feed_url' => 'https://blog.example.com/feed']);
$result = app(RunFetchRssNode::class)($run, ['feed_url' => 'https://1.1.1.1/feed']);
// Reads the production watermark, so it finds the newer items.
expect($result->status)->toBe(NodeRunStatus::Completed);
@ -191,12 +191,12 @@
it('advances the production watermark on a non-manual real-data run', function () {
Carbon::setTestNow('2026-01-15 10:00:00');
Http::fake(['blog.example.com/*' => Http::response(FETCH_RSS_MIXED, 200)]);
Http::fake(['1.1.1.1/*' => Http::response(FETCH_RSS_MIXED, 200)]);
$automation = Automation::factory()->active()->create([
'nodes' => [
['id' => 'trigger_1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule']],
['id' => 'fetch_1', 'type' => 'fetch_rss', 'position' => ['x' => 200, 'y' => 0], 'data' => ['feed_url' => 'https://blog.example.com/feed']],
['id' => 'fetch_1', 'type' => 'fetch_rss', 'position' => ['x' => 200, 'y' => 0], 'data' => ['feed_url' => 'https://1.1.1.1/feed']],
['id' => 'end_1', 'type' => 'end', 'position' => ['x' => 400, 'y' => 0], 'data' => []],
],
'connections' => [
@ -217,7 +217,7 @@
'is_dry_run' => false,
]);
app(RunFetchRssNode::class)($run, ['feed_url' => 'https://blog.example.com/feed']);
app(RunFetchRssNode::class)($run, ['feed_url' => 'https://1.1.1.1/feed']);
$state = AutomationNodeState::for($automation->id, 'fetch_1');
expect(CarbonImmutable::parse($state->data['last_item_date'])->toIso8601String())

View file

@ -127,7 +127,7 @@
$action = app(RunGenerateNode::class);
$accountsConfig = [
['social_account_id' => '1', 'content_type' => ContentType::XPost->value, 'meta' => []],
['social_account_id' => '1', 'content_type' => ContentType::LinkedInPost->value, 'meta' => []],
['social_account_id' => '2', 'content_type' => ContentType::InstagramReel->value, 'meta' => []],
];

View file

@ -18,14 +18,14 @@
it('runs in single-response mode when items_path is empty', function () {
Http::fake([
'api.example.com/*' => Http::response(['title' => 'Hello', 'id' => 42], 200),
'1.1.1.1/*' => Http::response(['title' => 'Hello', 'id' => 42], 200),
]);
$automation = Automation::factory()->active()->create();
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'http_1']);
$result = app(RunHttpRequestNode::class)($run, [
'url' => 'https://api.example.com/lookup',
'url' => 'https://1.1.1.1/lookup',
'method' => 'GET',
'auth_type' => 'none',
]);
@ -35,10 +35,27 @@
expect($result->output['fetch']['spawned'])->toBe(0);
});
it('blocks a request to a private or reserved address', function () {
Http::fake();
$automation = Automation::factory()->active()->create();
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'http_1']);
$result = app(RunHttpRequestNode::class)($run, [
'url' => 'http://127.0.0.1/internal',
'method' => 'GET',
'auth_type' => 'none',
]);
expect($result->status)->toBe(NodeRunStatus::Failed);
expect($result->error['reason'])->toBe('url_not_allowed');
Http::assertNothingSent();
});
it('processes first new item and spawns siblings when items_path is set', function () {
Carbon::setTestNow('2026-01-15 10:00:00');
Http::fake([
'api.example.com/*' => Http::response([
'1.1.1.1/*' => Http::response([
'items' => [
['id' => 'a1', 'published_at' => '2025-12-31T00:00:00Z'],
['id' => 'a2', 'published_at' => '2026-01-05T00:00:00Z'],
@ -68,7 +85,7 @@
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'http_1']);
$result = app(RunHttpRequestNode::class)($run, [
'url' => 'https://api.example.com/items',
'url' => 'https://1.1.1.1/items',
'method' => 'GET',
'auth_type' => 'none',
'items_path' => 'items',
@ -83,13 +100,13 @@
});
it('sends bearer token header decrypting it on the fly', function () {
Http::fake(['api.example.com/*' => Http::response(['ok' => true], 200)]);
Http::fake(['1.1.1.1/*' => Http::response(['ok' => true], 200)]);
$automation = Automation::factory()->active()->create();
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'http_1']);
app(RunHttpRequestNode::class)($run, [
'url' => 'https://api.example.com/me',
'url' => 'https://1.1.1.1/me',
'method' => 'GET',
'auth_type' => 'bearer',
'auth_token' => Crypt::encryptString('secret-token-123'),
@ -99,13 +116,13 @@
});
it('sends api_key header with custom header name', function () {
Http::fake(['api.example.com/*' => Http::response(['ok' => true], 200)]);
Http::fake(['1.1.1.1/*' => Http::response(['ok' => true], 200)]);
$automation = Automation::factory()->active()->create();
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'http_1']);
app(RunHttpRequestNode::class)($run, [
'url' => 'https://api.example.com/me',
'url' => 'https://1.1.1.1/me',
'method' => 'GET',
'auth_type' => 'api_key',
'auth_header_name' => 'X-Custom-Key',
@ -116,7 +133,7 @@
});
it('posts a body rendered from the template with run context', function () {
Http::fake(['api.example.com/*' => Http::response(['ok' => true], 200)]);
Http::fake(['1.1.1.1/*' => Http::response(['ok' => true], 200)]);
$automation = Automation::factory()->active()->create();
$run = AutomationRun::factory()->for($automation)->create([
@ -125,7 +142,7 @@
]);
app(RunHttpRequestNode::class)($run, [
'url' => 'https://api.example.com/notify',
'url' => 'https://1.1.1.1/notify',
'method' => 'POST',
'auth_type' => 'none',
'body_template' => '{"post_id":"{{ trigger.post.id }}"}',
@ -135,13 +152,13 @@
});
it('sends the branded user-agent header', function () {
Http::fake(['api.example.com/*' => Http::response(['ok' => true], 200)]);
Http::fake(['1.1.1.1/*' => Http::response(['ok' => true], 200)]);
$automation = Automation::factory()->active()->create();
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'http_1']);
app(RunHttpRequestNode::class)($run, [
'url' => 'https://api.example.com/me',
'url' => 'https://1.1.1.1/me',
'method' => 'GET',
'auth_type' => 'none',
'headers' => ['User-Agent' => 'user-supplied-agent'],
@ -151,13 +168,13 @@
});
it('sends custom headers configured in the editor', function () {
Http::fake(['api.example.com/*' => Http::response(['ok' => true], 200)]);
Http::fake(['1.1.1.1/*' => Http::response(['ok' => true], 200)]);
$automation = Automation::factory()->active()->create();
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'http_1']);
app(RunHttpRequestNode::class)($run, [
'url' => 'https://api.example.com/me',
'url' => 'https://1.1.1.1/me',
'method' => 'GET',
'auth_type' => 'none',
'headers' => ['X-Custom' => 'v'],

View file

@ -9,7 +9,7 @@
it('posts interpolated payload to the configured url', function () {
Http::fake([
'hooks.example.com/*' => Http::response(['ok' => true], 200),
'1.1.1.1/*' => Http::response(['ok' => true], 200),
]);
$run = AutomationRun::factory()->create([
@ -17,7 +17,7 @@
]);
$result = app(RunWebhookNode::class)($run, [
'url' => 'https://hooks.example.com/test',
'url' => 'https://1.1.1.1/test',
'method' => 'POST',
'headers' => ['X-Source' => 'TryPost'],
'payload_template' => '{"title":"{{ trigger.title }}","post_url":"{{ generated.post_url }}"}',
@ -29,13 +29,13 @@
it('sends the branded user-agent header', function () {
Http::fake([
'hooks.example.com/*' => Http::response(['ok' => true], 200),
'1.1.1.1/*' => Http::response(['ok' => true], 200),
]);
$run = AutomationRun::factory()->create();
app(RunWebhookNode::class)($run, [
'url' => 'https://hooks.example.com/test',
'url' => 'https://1.1.1.1/test',
'method' => 'POST',
'headers' => ['User-Agent' => 'user-supplied-agent'],
'payload_template' => '{}',
@ -45,12 +45,12 @@
});
it('fails on 5xx response', function () {
Http::fake(['hooks.example.com/*' => Http::response('err', 500)]);
Http::fake(['1.1.1.1/*' => Http::response('err', 500)]);
$run = AutomationRun::factory()->create();
$result = app(RunWebhookNode::class)($run, [
'url' => 'https://hooks.example.com/test',
'url' => 'https://1.1.1.1/test',
'method' => 'POST',
'payload_template' => '{}',
]);
@ -59,12 +59,12 @@
});
it('fails on malformed payload json instead of silently sending an empty body', function () {
Http::fake(['hooks.example.com/*' => Http::response(['ok' => true], 200)]);
Http::fake(['1.1.1.1/*' => Http::response(['ok' => true], 200)]);
$run = AutomationRun::factory()->create();
$result = app(RunWebhookNode::class)($run, [
'url' => 'https://hooks.example.com/test',
'url' => 'https://1.1.1.1/test',
'method' => 'POST',
'payload_template' => '{ "a": }',
]);
@ -74,13 +74,61 @@
Http::assertNothingSent();
});
it('treats 4xx responses as completed (only 5xx fails)', function () {
Http::fake(['hooks.example.com/*' => Http::response('not found', 404)]);
it('does not fire a real request on a dry run', function () {
Http::fake(['1.1.1.1/*' => Http::response(['ok' => true], 200)]);
$run = AutomationRun::factory()->create(['is_dry_run' => true]);
$result = app(RunWebhookNode::class)($run, [
'url' => 'https://1.1.1.1/test',
'method' => 'POST',
'payload_template' => '{"a":1}',
]);
expect($result->status)->toBe(Status::Completed);
expect($result->output['webhook']['dry_run'])->toBeTrue();
Http::assertNothingSent();
});
it('still validates payload json on a dry run', function () {
Http::fake(['1.1.1.1/*' => Http::response(['ok' => true], 200)]);
$run = AutomationRun::factory()->create(['is_dry_run' => true]);
$result = app(RunWebhookNode::class)($run, [
'url' => 'https://1.1.1.1/test',
'method' => 'POST',
'payload_template' => '{ "a": }',
]);
expect($result->status)->toBe(Status::Failed);
expect($result->error['reason'])->toBe('invalid_payload_json');
Http::assertNothingSent();
});
it('blocks a request to a private or reserved address', function () {
Http::fake();
$run = AutomationRun::factory()->create();
$result = app(RunWebhookNode::class)($run, [
'url' => 'https://hooks.example.com/test',
'url' => 'http://169.254.169.254/latest/meta-data/',
'method' => 'POST',
'payload_template' => '{}',
]);
expect($result->status)->toBe(Status::Failed);
expect($result->error['reason'])->toBe('url_not_allowed');
Http::assertNothingSent();
});
it('treats 4xx responses as completed (only 5xx fails)', function () {
Http::fake(['1.1.1.1/*' => Http::response('not found', 404)]);
$run = AutomationRun::factory()->create();
$result = app(RunWebhookNode::class)($run, [
'url' => 'https://1.1.1.1/test',
'method' => 'POST',
'payload_template' => '{}',
]);

View file

@ -44,7 +44,7 @@
return <<<XML
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
<item><title>{$title}</title><link>https://blog.example.com/{$guid}</link><guid>{$guid}</guid><pubDate>{$pubDate}</pubDate></item>
<item><title>{$title}</title><link>https://1.1.1.1/{$guid}</link><guid>{$guid}</guid><pubDate>{$pubDate}</pubDate></item>
</channel></rss>
XML;
};
@ -92,7 +92,7 @@
$fakeContentAgents('Post from RSS item');
Http::fake([
'blog.example.com/*' => Http::response($rssFeed('Fresh News', 'fresh-1', 'Wed, 14 Jan 2026 12:00:00 +0000'), 200),
'1.1.1.1/*' => Http::response($rssFeed('Fresh News', 'fresh-1', 'Wed, 14 Jan 2026 12:00:00 +0000'), 200),
]);
$workspace = Workspace::factory()->create();
@ -101,7 +101,7 @@
$automation = Automation::factory()->for($workspace)->active()->create([
'nodes' => [
['id' => 't', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule']],
['id' => 'f', 'type' => 'fetch_rss', 'position' => ['x' => 1, 'y' => 0], 'data' => ['feed_url' => 'https://blog.example.com/feed']],
['id' => 'f', 'type' => 'fetch_rss', 'position' => ['x' => 1, 'y' => 0], 'data' => ['feed_url' => 'https://1.1.1.1/feed']],
['id' => 'g', 'type' => 'generate', 'position' => ['x' => 2, 'y' => 0], 'data' => [
'accounts' => [
['social_account_id' => (string) $account->id, 'content_type' => 'instagram_feed', 'meta' => []],
@ -150,8 +150,8 @@
it('runs http_request → condition (true) → webhook down the yes handle', function () {
Http::fake([
'api.example.com/*' => Http::response(['status' => 'active'], 200),
'hooks.example.com/*' => Http::response(['ok' => true], 200),
'8.8.8.8/*' => Http::response(['status' => 'active'], 200),
'9.9.9.9/*' => Http::response(['ok' => true], 200),
]);
$workspace = Workspace::factory()->create();
@ -159,9 +159,9 @@
$automation = Automation::factory()->for($workspace)->active()->create([
'nodes' => [
['id' => 't', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule']],
['id' => 'h', 'type' => 'http_request', 'position' => ['x' => 1, 'y' => 0], 'data' => ['url' => 'https://api.example.com/status', 'method' => 'GET']],
['id' => 'h', 'type' => 'http_request', 'position' => ['x' => 1, 'y' => 0], 'data' => ['url' => 'https://8.8.8.8/status', 'method' => 'GET']],
['id' => 'c', 'type' => 'condition', 'position' => ['x' => 2, 'y' => 0], 'data' => ['field' => '{{ fetched.status }}', 'operator' => 'equals', 'value' => 'active']],
['id' => 'w', 'type' => 'webhook', 'position' => ['x' => 3, 'y' => 0], 'data' => ['url' => 'https://hooks.example.com/notify', 'method' => 'POST', 'payload_template' => '{"ok":true}']],
['id' => 'w', 'type' => 'webhook', 'position' => ['x' => 3, 'y' => 0], 'data' => ['url' => 'https://9.9.9.9/notify', 'method' => 'POST', 'payload_template' => '{"ok":true}']],
['id' => 'e', 'type' => 'end', 'position' => ['x' => 3, 'y' => 1], 'data' => []],
],
'connections' => [
@ -177,13 +177,13 @@
$run = $automation->runs()->latest()->first();
expect($run->status)->toBe(RunStatus::Completed);
Http::assertSent(fn ($request) => str_contains($request->url(), 'hooks.example.com/notify'));
Http::assertSent(fn ($request) => str_contains($request->url(), '9.9.9.9/notify'));
});
it('runs http_request → condition (false) → end down the no handle without hitting the webhook', function () {
Http::fake([
'api.example.com/*' => Http::response(['status' => 'inactive'], 200),
'hooks.example.com/*' => Http::response(['ok' => true], 200),
'8.8.8.8/*' => Http::response(['status' => 'inactive'], 200),
'9.9.9.9/*' => Http::response(['ok' => true], 200),
]);
$workspace = Workspace::factory()->create();
@ -191,9 +191,9 @@
$automation = Automation::factory()->for($workspace)->active()->create([
'nodes' => [
['id' => 't', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule']],
['id' => 'h', 'type' => 'http_request', 'position' => ['x' => 1, 'y' => 0], 'data' => ['url' => 'https://api.example.com/status', 'method' => 'GET']],
['id' => 'h', 'type' => 'http_request', 'position' => ['x' => 1, 'y' => 0], 'data' => ['url' => 'https://8.8.8.8/status', 'method' => 'GET']],
['id' => 'c', 'type' => 'condition', 'position' => ['x' => 2, 'y' => 0], 'data' => ['field' => '{{ fetched.status }}', 'operator' => 'equals', 'value' => 'active']],
['id' => 'w', 'type' => 'webhook', 'position' => ['x' => 3, 'y' => 0], 'data' => ['url' => 'https://hooks.example.com/notify', 'method' => 'POST', 'payload_template' => '{"ok":true}']],
['id' => 'w', 'type' => 'webhook', 'position' => ['x' => 3, 'y' => 0], 'data' => ['url' => 'https://9.9.9.9/notify', 'method' => 'POST', 'payload_template' => '{"ok":true}']],
['id' => 'e', 'type' => 'end', 'position' => ['x' => 3, 'y' => 1], 'data' => []],
],
'connections' => [
@ -209,14 +209,14 @@
$run = $automation->runs()->latest()->first();
expect($run->status)->toBe(RunStatus::Completed);
Http::assertNotSent(fn ($request) => str_contains($request->url(), 'hooks.example.com/notify'));
Http::assertNotSent(fn ($request) => str_contains($request->url(), '9.9.9.9/notify'));
});
it('runs fetch_rss → (no_items) → end when the feed yields no new items and creates no post', function () use ($rssFeed) {
Carbon::setTestNow('2026-01-15 10:00:00');
Http::fake([
'blog.example.com/*' => Http::response($rssFeed('Stale', 'stale-1', 'Mon, 01 Jan 2024 12:00:00 +0000'), 200),
'1.1.1.1/*' => Http::response($rssFeed('Stale', 'stale-1', 'Mon, 01 Jan 2024 12:00:00 +0000'), 200),
]);
$workspace = Workspace::factory()->create();
@ -224,7 +224,7 @@
$automation = Automation::factory()->for($workspace)->active()->create([
'nodes' => [
['id' => 't', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule']],
['id' => 'f', 'type' => 'fetch_rss', 'position' => ['x' => 1, 'y' => 0], 'data' => ['feed_url' => 'https://blog.example.com/feed']],
['id' => 'f', 'type' => 'fetch_rss', 'position' => ['x' => 1, 'y' => 0], 'data' => ['feed_url' => 'https://1.1.1.1/feed']],
['id' => 'g', 'type' => 'generate', 'position' => ['x' => 2, 'y' => 0], 'data' => ['prompt_template' => 'x', 'include_image' => false]],
['id' => 'e', 'type' => 'end', 'position' => ['x' => 2, 'y' => 1], 'data' => []],
],

View file

@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
use App\Actions\Automation\Node\RunConditionNode;
use App\Actions\Automation\Node\RunFetchRssNode;
use App\Actions\Automation\Node\RunWebhookNode;
use App\Enums\UserWorkspace\Role;
use App\Http\Resources\AutomationResource;
use App\Models\Automation;
use App\Models\AutomationRun;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Automation\ExpressionResolver;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
$this->workspace = Workspace::factory()->create();
$this->user = User::factory()->create(['current_workspace_id' => $this->workspace->id]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Admin->value]);
$this->user->refresh();
});
it('stores variable values encrypted and decrypts them for runtime', function () {
$automation = Automation::factory()->for($this->workspace)->create();
$this->actingAs($this->user)
->put(route('app.automations.update', $automation->id), [
'variables' => [
['key' => 'API_KEY', 'value' => 'super-secret'],
['key' => 'BASE_URL', 'value' => 'https://api.example.com'],
],
])
->assertRedirect();
$automation->refresh();
expect($automation->variables[0]['value'])->not->toBe('super-secret');
expect($automation->resolvedVariables())->toBe([
'API_KEY' => 'super-secret',
'BASE_URL' => 'https://api.example.com',
]);
});
it('masks variable values in the resource', function () {
$automation = Automation::factory()->for($this->workspace)->create([
'variables' => [['key' => 'API_KEY', 'value' => 'super-secret']],
]);
$resource = (new AutomationResource($automation->fresh()))->toArray(request());
expect($resource['variables'][0]['key'])->toBe('API_KEY');
expect($resource['variables'][0]['value'])->toBe(Automation::SENSITIVE_PLACEHOLDER);
});
it('keeps the stored value when the masked placeholder is re-saved', function () {
$automation = Automation::factory()->for($this->workspace)->create([
'variables' => [['key' => 'API_KEY', 'value' => 'secret-1']],
]);
$this->actingAs($this->user)
->put(route('app.automations.update', $automation->id), [
'variables' => [['key' => 'API_KEY', 'value' => Automation::SENSITIVE_PLACEHOLDER]],
])
->assertRedirect();
expect($automation->fresh()->resolvedVariables())->toBe(['API_KEY' => 'secret-1']);
});
it('resolves {{ variables.X }} in a node template', function () {
$resolved = app(ExpressionResolver::class)->resolve(
'Bearer {{ variables.API_KEY }}',
['variables' => ['API_KEY' => 'abc123']],
);
expect($resolved)->toBe('Bearer abc123');
});
it('merges variables into resolverContext but never into the persisted run context', function () {
$automation = Automation::factory()->for($this->workspace)->create([
'variables' => [['key' => 'API_KEY', 'value' => 'secret-xyz']],
]);
$run = AutomationRun::factory()->for($automation)->create(['context' => ['trigger' => ['x' => 1]]]);
expect($run->resolverContext()['variables'])->toBe(['API_KEY' => 'secret-xyz']);
expect($run->context)->not->toHaveKey('variables');
expect($run->fresh()->context)->not->toHaveKey('variables');
});
it('resolves a variable in a webhook payload without persisting it in the run context', function () {
Http::fake(['1.1.1.1/*' => Http::response(['ok' => true], 200)]);
$automation = Automation::factory()->for($this->workspace)->create([
'variables' => [['key' => 'TOKEN', 'value' => 'abc123']],
]);
$run = AutomationRun::factory()->for($automation)->create(['context' => []]);
app(RunWebhookNode::class)($run, [
'url' => 'https://1.1.1.1/hook',
'method' => 'POST',
'payload_template' => '{"token":"{{ variables.TOKEN }}"}',
]);
Http::assertSent(fn ($request) => $request['token'] === 'abc123');
expect($run->context)->not->toHaveKey('variables');
});
it('resolves a variable in an RSS feed url', function () {
Http::fake(['1.1.1.1/*' => Http::response('<rss><channel></channel></rss>', 200)]);
$automation = Automation::factory()->for($this->workspace)->create([
'variables' => [['key' => 'HOST', 'value' => '1.1.1.1']],
]);
$run = AutomationRun::factory()->for($automation)->create(['context' => []]);
app(RunFetchRssNode::class)($run, ['feed_url' => 'https://{{ variables.HOST }}/feed.xml']);
Http::assertSent(fn ($request) => str_contains($request->url(), '1.1.1.1/feed.xml'));
});
it('resolves a variable in a condition comparison value', function () {
$automation = Automation::factory()->for($this->workspace)->create([
'variables' => [['key' => 'EXPECTED', 'value' => 'published']],
]);
$run = AutomationRun::factory()->for($automation)->create([
'context' => ['trigger' => ['post' => ['status' => 'published']]],
]);
$result = app(RunConditionNode::class)($run, [
'field' => '{{ trigger.post.status }}',
'operator' => 'equals',
'value' => '{{ variables.EXPECTED }}',
]);
expect($result->nextHandle)->toBe('yes');
});
it('rejects an invalid variable key', function () {
$automation = Automation::factory()->for($this->workspace)->create();
$this->actingAs($this->user)
->putJson(route('app.automations.update', $automation->id), [
'variables' => [['key' => 'bad key!', 'value' => 'x']],
])
->assertStatus(422)
->assertJsonValidationErrors(['variables.0.key']);
});
it('rejects duplicate variable keys', function () {
$automation = Automation::factory()->for($this->workspace)->create();
$this->actingAs($this->user)
->putJson(route('app.automations.update', $automation->id), [
'variables' => [
['key' => 'API_KEY', 'value' => 'a'],
['key' => 'API_KEY', 'value' => 'b'],
],
])
->assertStatus(422)
->assertJsonValidationErrors(['variables.0.key']);
});