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:
parent
4efaa0bf99
commit
9a692b4608
49 changed files with 1723 additions and 655 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
92
app/Services/Automation/GenerateNodeValidator.php
Normal file
92
app/Services/Automation/GenerateNodeValidator.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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.',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -53,5 +53,6 @@
|
|||
'actions' => [
|
||||
'copy' => 'Copy',
|
||||
'copied' => 'Copied',
|
||||
'copy_failed' => 'Failed to copy to clipboard',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -53,5 +53,6 @@
|
|||
'actions' => [
|
||||
'copy' => 'Copiar',
|
||||
'copied' => 'Copiado',
|
||||
'copy_failed' => 'No se pudo copiar al portapapeles',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -53,5 +53,6 @@
|
|||
'actions' => [
|
||||
'copy' => 'Copiar',
|
||||
'copied' => 'Copiado',
|
||||
'copy_failed' => 'Falha ao copiar para a área de transferência',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
173
resources/js/components/ChannelConfigurator.vue
Normal file
173
resources/js/components/ChannelConfigurator.vue
Normal 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"> · @{{ 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
88
resources/js/components/automations/BuildPanel.vue
Normal file
88
resources/js/components/automations/BuildPanel.vue
Normal 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>
|
||||
71
resources/js/components/automations/EditorSidebar.vue
Normal file
71
resources/js/components/automations/EditorSidebar.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
89
resources/js/components/automations/VariablesPanel.vue
Normal file
89
resources/js/components/automations/VariablesPanel.vue
Normal 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>
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"> · @{{ 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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
43
resources/js/types/channel.ts
Normal file
43
resources/js/types/channel.ts
Normal 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[];
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
123
tests/Feature/Automation/GenerateNodeValidationTest.php
Normal file
123
tests/Feature/Automation/GenerateNodeValidationTest.php
Normal 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]);
|
||||
});
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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' => []],
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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' => '{}',
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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' => []],
|
||||
],
|
||||
|
|
|
|||
161
tests/Feature/Automation/VariablesTest.php
Normal file
161
tests/Feature/Automation/VariablesTest.php
Normal 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']);
|
||||
});
|
||||
Loading…
Reference in a new issue