Merge pull request #88 from trypostit/feat/automations-module

Automations ⚙️
This commit is contained in:
Paulo Castellano 2026-06-13 18:08:04 -03:00 committed by GitHub
commit 44fb42b500
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
283 changed files with 17912 additions and 633 deletions

View file

@ -265,6 +265,8 @@ ## Pagination
- Always use normal pagination (`->paginate()`). NEVER use cursor pagination (`->cursorPaginate()`).
- All paginated lists must use Inertia's scroll pagination (`Inertia::scroll()` on the backend with `<InfiniteScroll>` on the frontend). NEVER use traditional page-based pagination with page links/buttons.
- The page size ALWAYS comes from `config('app.pagination.default')` — never a magic number, and never a `perPage`/`per_page` value supplied by the request or frontend. Action/service list methods must NOT accept a `$perPage` parameter; call `->paginate((int) config('app.pagination.default'))` directly.
- The only exception is the public REST API (`app/Http/Controllers/Api`), which uses its own fixed, documented page size (15) as a stable API contract.
## Form Validation

View file

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
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\AutomationConfigValidator;
use DomainException;
class ActivateAutomation
{
public function __construct(private AutomationConfigValidator $configValidator) {}
public function __invoke(Automation $automation): Automation
{
$this->validate($automation);
$automation->update([
'status' => Status::Active,
'activated_at' => now(),
'paused_at' => null,
]);
return $automation;
}
private function validate(Automation $automation): void
{
$nodes = $automation->nodes ?? [];
$connections = $automation->connections ?? [];
$triggers = collect($nodes)->where('type', NodeType::Trigger->value);
if ($triggers->count() !== 1) {
throw new DomainException(__('automations.errors.must_have_one_trigger'));
}
$trigger = $triggers->first();
$hasTargetFromTrigger = collect($connections)->contains('source', $trigger['id']);
if (! $hasTargetFromTrigger) {
throw new DomainException(__('automations.errors.trigger_must_be_connected'));
}
$issue = $this->configValidator->firstMessage($nodes);
if ($issue !== null) {
throw new DomainException($issue);
}
}
}

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Automation;
use App\Enums\Automation\ScheduleField;
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
{
public function __invoke(Workspace $workspace, User $user, ?string $name = null): Automation
{
return Automation::create([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
'name' => $name ?: __('automations.default_name'),
'status' => Status::Draft,
'nodes' => [$this->defaultTriggerNode()],
'connections' => [],
]);
}
/**
* Every automation has exactly one trigger — its entry point — so we seed it
* on creation. The trigger can't be added or deleted from the editor; only
* its type (schedule / post published / post scheduled) is configurable.
*
* @return array<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' => ScheduleField::Days->value,
'schedule_days_interval' => 1,
'schedule_hour' => 9,
'schedule_minute' => 0,
'schedule_timezone' => config('app.timezone'),
],
];
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Automation;
use App\Models\Automation;
class DeleteAutomation
{
public function __invoke(Automation $automation): void
{
$automation->delete();
}
}

View file

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Automation;
use App\Enums\SocialAccount\Platform;
use App\Models\Automation;
use App\Models\SocialAccount;
use App\Services\Social\PinterestPublisher;
use App\Services\Social\TikTokCreatorInfo;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Collection as SupportCollection;
class GetAutomationEditorData
{
public function __construct(
private PinterestPublisher $pinterestPublisher,
private TikTokCreatorInfo $tikTokCreatorInfo,
) {}
/**
* @return array{
* socialAccounts: Collection<int, SocialAccount>,
* pinterestBoards: SupportCollection<string, array<int, mixed>>,
* tiktokCreatorInfos: SupportCollection<string, mixed>,
* }
*/
public function __invoke(Automation $automation): array
{
$socialAccounts = $automation->workspace->socialAccounts()->active()->get();
$pinterestBoards = $socialAccounts
->where('platform', Platform::Pinterest)
->mapWithKeys(fn ($account) => [
$account->id => rescue(
fn () => $this->pinterestPublisher->getBoards($account),
[],
report: false,
),
]);
$tiktokCreatorInfos = $socialAccounts
->where('platform', Platform::TikTok)
->mapWithKeys(fn ($account) => [
$account->id => rescue(
fn () => $this->tikTokCreatorInfo->fetch($account),
null,
report: false,
),
])
->filter();
return [
'socialAccounts' => $socialAccounts,
'pinterestBoards' => $pinterestBoards,
'tiktokCreatorInfos' => $tiktokCreatorInfos,
];
}
}

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Automation;
use App\Models\Automation;
use App\Models\AutomationRun;
use Illuminate\Contracts\Pagination\CursorPaginator;
use Illuminate\Pagination\LengthAwarePaginator;
class GetAutomationInvocations
{
/**
* Paginated, real (non-dry-run) executions for the Invocations tab, newest
* first, each carrying the count of node runs it produced so the list can
* render a "Workflow completed · N steps" summary without N+1 queries.
*
* @return LengthAwarePaginator<int, AutomationRun>
*/
public function __invoke(Automation $automation, ?string $status = null, ?string $search = null): LengthAwarePaginator|CursorPaginator
{
return $automation->runs()
->productionRuns()
->withCount('nodeRuns')
->when($status !== null, fn ($query) => $query->where('status', $status))
->when($search !== null && $search !== '', fn ($query) => $query->whereLike('id', "%{$search}%"))
->latest()
->paginate((int) config('app.pagination.default'));
}
}

View file

@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Automation;
use App\Enums\Automation\Run\Status;
use App\Models\Automation;
use App\Models\AutomationRun;
use App\Models\PostPlatform;
use Carbon\CarbonInterface;
use Illuminate\Support\Collection;
class GetAutomationMetrics
{
/**
* Aggregate run health and flow output for the Metrics tab over the given
* (inclusive) date range. App timezone is UTC, so day bucketing aligns with
* the stored UTC timestamps without conversion.
*
* @return array{
* totals: array{runs: int, completed: int, failed: int, in_progress: int, success_rate: ?int, avg_duration_ms: ?int, posts_created: int},
* timeseries: array<int, array{date: string, started: int, completed: int, failed: int}>,
* platforms: array<int, array{platform: string, count: int}>,
* }
*/
public function __invoke(Automation $automation, CarbonInterface $start, CarbonInterface $end): array
{
$start = $start->copy()->startOfDay();
$end = $end->copy()->endOfDay();
$days = (int) $start->diffInDays($end) + 1;
$runs = $automation->runs()
->productionRuns()
->whereBetween('created_at', [$start, $end])
->get(['id', 'status', 'generated_post_id', 'created_at', 'started_at', 'finished_at']);
$completed = $runs->where('status', Status::Completed);
$failed = $runs->where('status', Status::Failed);
$inProgress = $runs->whereIn('status', [Status::Pending, Status::Running, Status::Waiting]);
$finished = $completed->count() + $failed->count();
$successRate = $finished > 0 ? (int) round($completed->count() / $finished * 100) : null;
$durations = $completed
->map(fn ($run) => $run->durationInMilliseconds())
->filter(fn ($ms) => $ms !== null);
$avgDurationMs = $durations->isNotEmpty() ? (int) round($durations->avg()) : null;
return [
'totals' => [
'runs' => $runs->count(),
'completed' => $completed->count(),
'failed' => $failed->count(),
'in_progress' => $inProgress->count(),
'success_rate' => $successRate,
'avg_duration_ms' => $avgDurationMs,
'posts_created' => $runs->whereNotNull('generated_post_id')->count(),
],
'timeseries' => $this->buildTimeseries($runs, $start, $days),
'platforms' => $this->buildPlatformBreakdown($runs->pluck('generated_post_id')->filter()->all()),
];
}
/**
* Zero-filled daily buckets so the chart line stays continuous across days
* with no runs.
*
* @param Collection<int, AutomationRun> $runs
* @return array<int, array{date: string, started: int, completed: int, failed: int}>
*/
private function buildTimeseries(Collection $runs, CarbonInterface $since, int $days): array
{
$series = [];
for ($i = 0; $i < $days; $i++) {
$date = $since->copy()->addDays($i)->format('Y-m-d');
$series[$date] = ['date' => $date, 'started' => 0, 'completed' => 0, 'failed' => 0];
}
foreach ($runs as $run) {
$date = $run->created_at->format('Y-m-d');
if (! isset($series[$date])) {
continue;
}
$series[$date]['started']++;
if ($run->status === Status::Completed) {
$series[$date]['completed']++;
}
if ($run->status === Status::Failed) {
$series[$date]['failed']++;
}
}
return array_values($series);
}
/**
* Count published platform targets across the posts this automation
* generated, so the chart shows where its output actually went.
*
* @param array<int, string> $postIds
* @return array<int, array{platform: string, count: int}>
*/
private function buildPlatformBreakdown(array $postIds): array
{
if ($postIds === []) {
return [];
}
return PostPlatform::query()
->whereIn('post_id', $postIds)
->selectRaw('platform, count(*) as total')
->groupBy('platform')
->orderByDesc('total')
->get()
->map(fn ($row) => ['platform' => $row->platform->value, 'count' => (int) $row->total])
->all();
}
}

View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Automation;
use App\Models\Automation;
use App\Models\Workspace;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
class ListAutomations
{
public function __invoke(Workspace $workspace): LengthAwarePaginator
{
return Automation::query()
->where('workspace_id', $workspace->id)
->orderByDesc('created_at')
->paginate((int) config('app.pagination.default'));
}
}

View file

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

View file

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Automation;
use App\Models\Automation;
use DomainException;
class UpdateAutomation
{
public function __invoke(Automation $automation, array $data): Automation
{
$this->detectCycles($data['nodes'] ?? [], $data['connections'] ?? []);
$automation->update([
'name' => $data['name'] ?? $automation->name,
'nodes' => $data['nodes'] ?? $automation->nodes,
'connections' => $data['connections'] ?? $automation->connections,
'variables' => $data['variables'] ?? $automation->variables,
]);
return $automation->fresh();
}
private function detectCycles(array $nodes, array $connections): void
{
$adj = [];
foreach ($connections as $c) {
$adj[$c['source']][] = $c['target'];
}
/** @var array<string, string> $state state: 'white' (unvisited), 'gray' (in stack), 'black' (done) */
$state = [];
foreach ($nodes as $node) {
$state[$node['id']] = 'white';
}
foreach ($nodes as $node) {
if ($state[$node['id']] === 'white' && $this->hasCycleFrom($node['id'], $adj, $state)) {
throw new DomainException(__('automations.errors.graph_contains_cycle'));
}
}
}
private function hasCycleFrom(string $node, array $adj, array &$state): bool
{
$state[$node] = 'gray';
foreach ($adj[$node] ?? [] as $next) {
if (! isset($state[$next])) {
continue;
}
if ($state[$next] === 'gray') {
return true;
}
if ($state[$next] === 'white' && $this->hasCycleFrom($next, $adj, $state)) {
return true;
}
}
$state[$node] = 'black';
return false;
}
}

View file

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

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Node;
use App\DataTransferObjects\Automation\NodeRunResult;
use App\Enums\Automation\DelayUnit;
use App\Models\AutomationRun;
use InvalidArgumentException;
class RunDelayNode
{
public function __invoke(AutomationRun $run, array $config): NodeRunResult
{
$duration = (int) data_get($config, 'duration', 0);
$unit = data_get($config, 'unit', DelayUnit::Minutes->value);
$until = match (DelayUnit::tryFrom((string) $unit)) {
DelayUnit::Minutes => now()->addMinutes($duration),
DelayUnit::Hours => now()->addHours($duration),
DelayUnit::Days => now()->addDays($duration),
default => throw new InvalidArgumentException("Unknown delay unit: {$unit}"),
};
return NodeRunResult::sleep($until);
}
}

View file

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

View file

@ -0,0 +1,215 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Node;
use App\Actions\Automation\Run\AdvanceAutomationRun;
use App\DataTransferObjects\Automation\NodeRunResult;
use App\Enums\Automation\Run\Status as RunStatus;
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 RuntimeException;
use SimpleXMLElement;
use Throwable;
/**
* Fetches an RSS feed and progresses the run with the next-up unseen item.
*
* - Filters by a per-node watermark stored in `automation_node_states`, so the
* very first run on an old feed processes none of the historical items.
* - When the fetch returns N new items, the current run takes item[0]; the
* remaining N-1 items are spawned as sibling runs that resume at the node
* immediately after this Fetch (with `context.fetched` already populated),
* so each item ends up generating its own Post / Webhook / etc.
* - When the feed yields no new items, the result short-circuits via the
* `no_items` handle; if the user hasn't wired anything to it, the run
* completes silently (handled by AdvanceAutomationRun's default branch).
*/
class RunFetchRssNode
{
private const ITEM_HANDLE = 'default';
private const NO_ITEMS_HANDLE = 'no_items';
public function __construct(
private ExpressionResolver $resolver,
private SafeHttpFetcher $safeHttp,
private AdvanceAutomationRun $advance,
) {}
public function __invoke(AutomationRun $run, array $config): NodeRunResult
{
$feedUrl = $this->resolver->resolve((string) data_get($config, 'feed_url', ''), $run->resolverContext());
if ($feedUrl === '') {
return NodeRunResult::failed(__('automations.errors.fetch_rss_missing_url'));
}
try {
$this->safeHttp->guardAgainstSsrf($feedUrl);
} catch (RuntimeException) {
return NodeRunResult::failed(__('automations.errors.url_not_allowed'), [
'reason' => 'url_not_allowed',
'url' => $feedUrl,
]);
}
$response = Http::get($feedUrl);
if (! $response->successful()) {
return NodeRunResult::failed(__('automations.errors.fetch_rss_request_failed'), ['status' => $response->status()]);
}
try {
$xml = new SimpleXMLElement($response->body());
} catch (Throwable) {
return NodeRunResult::failed(__('automations.errors.fetch_rss_malformed'));
}
$nodeId = (string) $run->current_node_id;
// Test runs (dry OR real-data) bypass the watermark entirely: they use an
// epoch watermark so every item is treated as new, process only the first
// item, and never spawn siblings or advance the watermark. This way a
// test ALWAYS shows real data flowing through (instead of "no new items")
// and never floods the feed or poisons the production watermark.
$isPreview = $run->is_manual || $run->is_dry_run;
$state = $isPreview ? null : AutomationNodeState::for($run->automation_id, $nodeId);
$watermark = $isPreview
? CarbonImmutable::createFromTimestamp(0)
: $this->parseWatermark(data_get($state->data, 'last_item_date'));
[$newItems, $newestSeen] = $this->collectNewItems($xml, $watermark);
if ($state !== null && $newestSeen !== null) {
$state->update(['data' => array_merge($state->data ?? [], [
'last_item_date' => $newestSeen->toIso8601String(),
])]);
}
if ($newItems === []) {
return NodeRunResult::completed(['fetch' => ['count' => 0]], nextHandle: self::NO_ITEMS_HANDLE);
}
$first = array_shift($newItems);
if (! $isPreview) {
$this->spawnSiblings($run, $nodeId, $newItems);
}
return NodeRunResult::completed([
'fetch' => ['count' => count($newItems) + 1, 'spawned' => $isPreview ? 0 : count($newItems)],
'fetched' => $first,
]);
}
private function collectNewItems(SimpleXMLElement $xml, CarbonImmutable $watermark): array
{
$items = [];
$newestSeen = null;
foreach ($xml->channel->item ?? [] as $item) {
$key = (string) ($item->guid ?? $item->link);
if ($key === '') {
continue;
}
$pubDate = $this->parsePubDate((string) $item->pubDate);
if ($pubDate === null) {
continue;
}
if ($newestSeen === null || $pubDate->greaterThan($newestSeen)) {
$newestSeen = $pubDate;
}
if (! $pubDate->greaterThan($watermark)) {
continue;
}
$items[] = [
'_pubDate' => $pubDate,
'key' => $key,
'title' => (string) $item->title,
'link' => (string) $item->link,
'description' => (string) $item->description,
'pubDate' => (string) $item->pubDate,
];
}
// Process oldest-first so siblings inherit a stable order matching feed chronology.
usort($items, fn ($a, $b) => $a['_pubDate']->getTimestamp() <=> $b['_pubDate']->getTimestamp());
// Drop the internal sort key — downstream nodes shouldn't see it.
$items = array_map(function (array $item): array {
unset($item['_pubDate']);
return $item;
}, $items);
return [$items, $newestSeen];
}
private function spawnSiblings(AutomationRun $parent, string $fetchNodeId, array $items): void
{
if ($items === []) {
return;
}
// Each remaining item gets its own run that fans out across EVERY branch
// wired to the fetch node — matching how item[0] (the current run) fans
// out, so no branch silently drops items 2..N.
$targets = $this->advance->targetsFor($parent->automation, $fetchNodeId, self::ITEM_HANDLE);
foreach ($items as $item) {
$sibling = AutomationRun::create([
'automation_id' => $parent->automation_id,
'root_run_id' => $parent->rootId(),
'trigger_item_id' => $parent->trigger_item_id,
'generated_post_id' => $parent->generated_post_id,
'is_manual' => $parent->is_manual,
'is_dry_run' => $parent->is_dry_run,
'status' => RunStatus::Pending,
'context' => array_merge($parent->context ?? [], ['fetched' => $item]),
]);
if ($targets === []) {
$sibling->update(['status' => RunStatus::Completed, 'finished_at' => now()]);
continue;
}
$this->advance->dispatchBranches($sibling, $targets);
}
}
private function parseWatermark(?string $stored): CarbonImmutable
{
if ($stored === null) {
return CarbonImmutable::now();
}
try {
return CarbonImmutable::parse($stored);
} catch (Throwable) {
return CarbonImmutable::now();
}
}
private function parsePubDate(string $raw): ?CarbonImmutable
{
if ($raw === '') {
return null;
}
try {
return CarbonImmutable::parse($raw);
} catch (Throwable) {
return null;
}
}
}

View file

@ -0,0 +1,356 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Node;
use App\Actions\Post\CreatePost;
use App\Ai\Agents\PostContentGenerator;
use App\Ai\Agents\PostContentHumanizer;
use App\DataTransferObjects\Automation\NodeRunResult;
use App\Enums\PostPlatform\ContentType;
use App\Models\AutomationRun;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Ai\RecordAiUsage;
use App\Services\Automation\ExpressionResolver;
use App\Services\Automation\GenerateNodeValidator;
use App\Services\Image\PostImagePipeline;
use Illuminate\Support\Facades\Log;
use Throwable;
class RunGenerateNode
{
public function __construct(
private ExpressionResolver $resolver,
) {}
public function __invoke(AutomationRun $run, array $config): NodeRunResult
{
$context = $run->resolverContext();
$prompt = $this->resolver->resolve((string) data_get($config, 'prompt_template', ''), $context);
$accountsConfig = $this->resolveAccountsConfig($config);
['format' => $format, 'slide_count' => $slideCount] = $this->deriveFormat($accountsConfig, $config);
$accountIds = array_values(array_filter(array_map(
fn ($a) => data_get($a, 'social_account_id'),
$accountsConfig,
)));
$workspace = $run->automation->workspace;
$activeAccounts = SocialAccount::query()
->whereIn('id', $accountIds)
->where('workspace_id', $workspace->id)
->active()
->get()
->keyBy('id');
// Per-automation toggle: when off, the brand persona/voice is NOT
// injected, so the post stays faithful to a third-party source (news /
// RSS curation) instead of being rewritten in the brand's voice.
$applyBrandVoice = (bool) data_get($config, 'use_brand_voice', true);
$platformContext = $this->resolvePlatformContext($accountsConfig);
$agent = new PostContentGenerator(
workspace: $workspace,
format: $format,
slideCount: $slideCount,
platformContext: $platformContext,
applyBrandVoice: $applyBrandVoice,
);
$generatorResponse = $agent->prompt($prompt);
RecordAiUsage::recordText(
workspace: $workspace,
promptTokens: $generatorResponse->usage->promptTokens,
completionTokens: $generatorResponse->usage->completionTokens,
provider: (string) config('ai.default'),
model: (string) config('ai.default_text_model'),
metadata: ['agent' => 'post_generator', 'format' => $format, 'source' => 'automation'],
);
$structured = $generatorResponse->structured ?? [];
$structured = $this->humanize($workspace, $structured, $format, $applyBrandVoice, $platformContext);
$content = $format === 'carousel'
? (string) data_get($structured, 'caption', '')
: (string) data_get($structured, 'content', '');
$user = $this->resolveUser($run);
$platforms = [];
foreach ($accountsConfig as $entry) {
$accountId = data_get($entry, 'social_account_id');
if (! $accountId || ! $activeAccounts->has($accountId)) {
if ($accountId) {
Log::warning('RunGenerateNode: account no longer active, skipping', [
'automation_id' => $run->automation_id,
'social_account_id' => $accountId,
]);
}
continue;
}
$platforms[] = [
'social_account_id' => $accountId,
'content_type' => data_get($entry, 'content_type'),
'meta' => data_get($entry, 'meta', []),
];
}
// A single source of truth for image count: 0 = text-only (no image),
// 1 = single image, 2+ = carousel. For the single-image branch we only
// care whether at least one image was requested.
$wantsImage = (int) data_get($config, 'target_slide_count', 1) >= 1;
$brandAccount = $platforms !== []
? $activeAccounts->get(data_get($platforms[0], 'social_account_id'))
: null;
$contentType = $platforms !== []
? ContentType::tryFrom((string) data_get($platforms[0], 'content_type'))
: null;
$imageCount = $this->intendedImageCount($format, $slideCount, $wantsImage, $structured, $brandAccount);
// Dry runs do the AI work (so the user sees a real generation) but
// never persist a Post. Downstream nodes (Publish) read `is_dry_run`
// and skip their persistence too.
if ($run->is_dry_run) {
return NodeRunResult::completed(output: [
'generated' => [
'post_id' => null,
'content' => $content,
'dry_run' => true,
'image_count' => $imageCount,
],
]);
}
$media = [];
$applyBrandVisuals = (bool) data_get($config, 'use_brand_visuals', true);
if ($brandAccount) {
if ($format === 'carousel') {
$media = app(PostImagePipeline::class)->forCarousel($workspace, $brandAccount, $structured, $contentType, $applyBrandVisuals);
} elseif ($wantsImage) {
$media = app(PostImagePipeline::class)->forSingle($workspace, $brandAccount, $structured, $contentType, $applyBrandVisuals);
}
}
$post = CreatePost::execute($workspace, $user, [
'content' => $content,
'media' => $media,
'platforms' => $platforms,
]);
$run->update(['generated_post_id' => $post->id]);
return NodeRunResult::completed(output: [
'generated' => [
'post_id' => $post->id,
'content' => $content,
'post_url' => route('app.posts.show', $post->id),
],
]);
}
/**
* @param array<string, mixed> $structured
* @return array<string, mixed>
*/
private function humanize(Workspace $workspace, array $structured, string $format, bool $applyBrandVoice = true, ?string $platformContext = null): array
{
try {
$input = $format === 'carousel'
? [
'caption' => data_get($structured, 'caption', ''),
'slides' => array_map(
fn ($s) => [
'title' => data_get($s, 'title', ''),
'body' => data_get($s, 'body', ''),
],
data_get($structured, 'slides', []),
),
]
: [
'content' => data_get($structured, 'content', ''),
'image_title' => data_get($structured, 'image_title', ''),
'image_body' => data_get($structured, 'image_body', ''),
];
$humanizer = new PostContentHumanizer($workspace, $format, platformContext: $platformContext, applyBrandVoice: $applyBrandVoice);
$response = $humanizer->prompt(json_encode($input, JSON_UNESCAPED_UNICODE));
$humanized = $response->structured ?? [];
RecordAiUsage::recordText(
workspace: $workspace,
promptTokens: $response->usage->promptTokens,
completionTokens: $response->usage->completionTokens,
provider: (string) config('ai.default'),
model: (string) config('ai.default_text_model'),
metadata: ['agent' => 'post_humanizer', 'format' => $format, 'source' => 'automation'],
);
if ($format === 'carousel') {
$structured['caption'] = data_get($humanized, 'caption', $structured['caption'] ?? '');
$originalSlides = $structured['slides'] ?? [];
$humanizedSlides = data_get($humanized, 'slides', []);
foreach ($originalSlides as $i => $slide) {
if (isset($humanizedSlides[$i])) {
$originalSlides[$i]['title'] = data_get($humanizedSlides[$i], 'title', $slide['title'] ?? '');
$originalSlides[$i]['body'] = data_get($humanizedSlides[$i], 'body', $slide['body'] ?? '');
}
}
$structured['slides'] = $originalSlides;
} else {
$structured['content'] = data_get($humanized, 'content', $structured['content'] ?? '');
$structured['image_title'] = data_get($humanized, 'image_title', $structured['image_title'] ?? '');
$structured['image_body'] = data_get($humanized, 'image_body', $structured['image_body'] ?? '');
}
} catch (Throwable $e) {
Log::warning('RunGenerateNode: PostContentHumanizer failed, using generator output as-is', [
'error' => $e->getMessage(),
]);
}
return $structured;
}
/**
* Derive the generator format and slide count from per-account content types.
*
* Carousel-capable content types:
* - instagram_feed (Instagram feed carousel = multi-image feed post)
* - linkedin_carousel (LinkedIn personal carousel PDF)
* - linkedin_page_carousel (LinkedIn page carousel PDF)
* - pinterest_carousel (Pinterest carousel pin)
* - tiktok_photo (TikTok photo carousel)
*
* When at least one account has a carousel-capable content type AND
* target_slide_count > 1, the generator is told to produce a carousel with
* that many slides. Otherwise it falls back to a single-post format.
*
* @param array<int, array{social_account_id: string, content_type: ?string, meta: array<string, mixed>}> $accountsConfig
* @param array<string, mixed> $config
* @return array{format: string, slide_count: int}
*/
public function deriveFormat(array $accountsConfig, array $config): array
{
// Multi-image capability comes from the same ContentType rules the
// publish flow uses — never a hardcoded list — so facebook_post,
// tiktok_photo, linkedin_carousel etc. are all recognised. We cap the
// slide count at MAX_GENERATED_IMAGES and at the tightest selected
// account's limit.
$maxImagesAcross = 0;
foreach ($accountsConfig as $entry) {
$contentType = ContentType::tryFrom((string) data_get($entry, 'content_type'));
if ($contentType instanceof ContentType && $contentType->supportsImage() && $contentType->maxMediaCount() > 1) {
$maxImagesAcross = max($maxImagesAcross, $contentType->maxMediaCount());
}
}
$targetSlideCount = (int) data_get($config, 'target_slide_count', 1);
if ($maxImagesAcross > 1 && $targetSlideCount > 1) {
$cap = min(GenerateNodeValidator::MAX_GENERATED_IMAGES, $maxImagesAcross);
return ['format' => 'carousel', 'slide_count' => min($targetSlideCount, $cap)];
}
return ['format' => 'single', 'slide_count' => 1];
}
/**
* Pick the content type the generator should write for so the copy fits
* every selected network. A Generate node can target one or many accounts,
* each with its own content type, so we feed the generator the MOST
* RESTRICTIVE platform (smallest character cap) — content that fits X (280)
* also fits LinkedIn (3000). Returns null when no account carries a known
* content type, leaving the generator platform-agnostic.
*
* @param array<int, array{social_account_id: string, content_type: ?string, meta: array<string, mixed>}> $accountsConfig
*/
private function resolvePlatformContext(array $accountsConfig): ?string
{
return collect($accountsConfig)
->map(fn ($entry) => ContentType::tryFrom((string) data_get($entry, 'content_type')))
->filter()
->sortBy(fn (ContentType $contentType) => $contentType->platform()->maxContentLength())
->first()?->value;
}
/**
* Number of images that would be attached for the resolved format. Used as
* the dry-run indicator and mirrors the non-dry image generation branches:
* one per slide for carousels, one for single posts when images are enabled.
*
* @param array<string, mixed> $structured
*/
private function intendedImageCount(string $format, int $slideCount, bool $wantsImage, array $structured, ?SocialAccount $brandAccount): int
{
if (! $brandAccount) {
return 0;
}
if ($format === 'carousel') {
$slides = data_get($structured, 'slides', []);
return is_array($slides) ? count($slides) : $slideCount;
}
return $wantsImage ? 1 : 0;
}
private function resolveUser(AutomationRun $run): User
{
if ($run->automation->user_id) {
return $run->automation->user;
}
return $run->automation->workspace->owner;
}
/**
* Read the current `accounts` shape and fall back to the legacy
* `social_account_ids` array so older automations keep running until
* the user re-opens and saves the node.
*
* @param array<string, mixed> $config
* @return array<int, array{social_account_id: string, content_type: ?string, meta: array<string, mixed>}>
*/
private function resolveAccountsConfig(array $config): array
{
$accounts = data_get($config, 'accounts');
if (is_array($accounts)) {
return array_values(array_map(fn ($entry) => [
'social_account_id' => (string) data_get($entry, 'social_account_id', ''),
'content_type' => data_get($entry, 'content_type'),
'meta' => (array) data_get($entry, 'meta', []),
], $accounts));
}
$legacy = data_get($config, 'social_account_ids', []);
if (! is_array($legacy)) {
return [];
}
return array_values(array_map(fn ($id) => [
'social_account_id' => (string) $id,
'content_type' => null,
'meta' => [],
], $legacy));
}
}

View file

@ -0,0 +1,471 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Node;
use App\Actions\Automation\Run\AdvanceAutomationRun;
use App\DataTransferObjects\Automation\NodeRunResult;
use App\Enums\Automation\AuthType;
use App\Enums\Automation\HttpMethod;
use App\Enums\Automation\Run\Status as RunStatus;
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\Http\Client\Response;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use Throwable;
/**
* Generalized HTTP node — supersedes the old `fetch_json` polling-only node.
*
* Two execution modes share one config:
* - **Single request mode**: the response is a single object (or `items_path`
* resolves to nothing), so the parsed body is set on `context.fetched` as a
* single payload. Useful for enrichment ("look up user X before generating").
* - **List mode**: the response is a collection, so each item drives its own
* downstream branch. The collection is found by, in order:
* 1. `items_path` (dot notation, e.g. `data.items`; `*` iterates a top-level
* object map's values),
* 2. an empty `items_path` + a top-level JSON array,
* 3. an empty `items_path` + an NDJSON (one JSON object per line) body.
* The current run takes the first new item; the rest spawn sibling runs.
*
* New items are detected (so a feed isn't reprocessed every poll) by, in order:
* - `item_date_path` — a per-node date watermark (cheapest, bounded), or
* - `item_key_path` — a per-node set of seen keys (covers ids/uuids/urls).
* Either way the FIRST poll records the baseline and emits nothing, so pointing
* the node at an existing feed never floods on day one.
*
* Auth types: none, bearer, basic, api_key. Credentials are stored encrypted
* on the Automation model (see `Automation::booted()`) and decrypted here.
*/
class RunHttpRequestNode
{
private const ITEM_HANDLE = 'default';
private const NO_ITEMS_HANDLE = 'no_items';
/**
* Upper bound on the per-node seen-key history. FIFO-evicts the oldest keys
* once exceeded — matching n8n's capped "history size" for its dedup store.
*/
private const MAX_SEEN_KEYS = 500;
public function __construct(
private ExpressionResolver $resolver,
private SafeHttpFetcher $safeHttp,
private AdvanceAutomationRun $advance,
) {}
public function __invoke(AutomationRun $run, array $config): NodeRunResult
{
$url = (string) data_get($config, 'url', '');
$method = strtoupper((string) data_get($config, 'method', HttpMethod::Get->value));
$nodeId = (string) $run->current_node_id;
$context = $run->resolverContext();
if ($url === '') {
return NodeRunResult::failed(__('automations.errors.http_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);
$jsonBody = $this->buildJsonBody($method, $config, $context);
try {
$response = match (HttpMethod::tryFrom($method)) {
HttpMethod::Get => $request->get($resolvedUrl),
HttpMethod::Delete => $request->delete($resolvedUrl),
HttpMethod::Post => $request->post($resolvedUrl, $jsonBody),
HttpMethod::Put => $request->put($resolvedUrl, $jsonBody),
HttpMethod::Patch => $request->patch($resolvedUrl, $jsonBody),
default => null,
};
} catch (Throwable $e) {
return NodeRunResult::failed(__('automations.errors.http_request_exception'), ['message' => $e->getMessage()]);
}
if ($response === null) {
return NodeRunResult::failed("Unsupported HTTP method: {$method}");
}
if (! $response->successful()) {
return NodeRunResult::failed(__('automations.errors.http_request_failed'), [
'status' => $response->status(),
'body' => substr($response->body(), 0, 500),
]);
}
$payload = $this->decodeBody($response);
$itemsPath = is_string($raw = data_get($config, 'items_path')) ? trim($raw) : '';
// No path + a top-level array (or NDJSON list) → iterate it. No path + a
// single object/scalar → single-response mode: forward the whole body.
if ($itemsPath === '') {
if (! is_array($payload) || ! array_is_list($payload)) {
return NodeRunResult::completed([
'fetch' => ['count' => 1, 'spawned' => 0],
'fetched' => $payload,
]);
}
return $this->processItems($run, $nodeId, $config, $payload);
}
// Explicit path: dot notation, or `*` to iterate a top-level object map.
$resolved = data_get($payload, $itemsPath);
if (! is_array($resolved)) {
return NodeRunResult::failed(__('automations.errors.http_items_path_not_array'));
}
return $this->processItems($run, $nodeId, $config, array_values($resolved));
}
/**
* Decodes the response as JSON, falling back to NDJSON (one JSON value per
* line) so streaming/log-style list endpoints work too.
*/
private function decodeBody(Response $response): mixed
{
$json = $response->json();
if (is_array($json)) {
return $json;
}
return $this->parseNdjson($response->body()) ?? $json;
}
/**
* @return array<int, mixed>|null The decoded list, or null when the body is
* not newline-delimited JSON.
*/
private function parseNdjson(string $body): ?array
{
$lines = preg_split('/\r\n|\r|\n/', trim($body)) ?: [];
$items = [];
foreach ($lines as $line) {
$line = trim($line);
if ($line === '') {
continue;
}
$decoded = json_decode($line, true);
if ($decoded === null && $line !== 'null') {
return null;
}
$items[] = $decoded;
}
return count($items) > 1 ? $items : null;
}
/**
* @param array<string, mixed> $config
* @param array<int, mixed> $items
*/
private function processItems(AutomationRun $run, string $nodeId, array $config, array $items): NodeRunResult
{
$itemKeyPath = is_string($k = data_get($config, 'item_key_path')) ? trim($k) : '';
$itemDatePath = is_string($d = data_get($config, 'item_date_path')) ? trim($d) : '';
// Test runs (dry OR real-data) walk a single item so the user sees data
// flow, without spawning siblings, advancing watermarks or recording keys.
if ($run->is_manual || $run->is_dry_run) {
if ($items === []) {
return NodeRunResult::completed(['fetch' => ['count' => 0]], nextHandle: self::NO_ITEMS_HANDLE);
}
return NodeRunResult::completed([
'fetch' => ['count' => count($items), 'spawned' => 0],
'fetched' => $items[0],
]);
}
// A date watermark is cheapest and bounded, so it wins when both are set;
// the seen-key set is the fallback for feeds without a usable date.
$newItems = match (true) {
$itemDatePath !== '' => $this->filterByDate($run->automation_id, $nodeId, $items, $itemDatePath),
$itemKeyPath !== '' => $this->filterBySeenKeys($run->automation_id, $nodeId, $items, $itemKeyPath),
default => $items,
};
if ($newItems === []) {
return NodeRunResult::completed(['fetch' => ['count' => 0]], nextHandle: self::NO_ITEMS_HANDLE);
}
$first = array_shift($newItems);
$this->spawnSiblings($run, $nodeId, $newItems);
return NodeRunResult::completed([
'fetch' => ['count' => count($newItems) + 1, 'spawned' => count($newItems)],
'fetched' => $first,
]);
}
/**
* Keeps only items newer than the per-node date watermark, then advances the
* watermark to the newest date seen. The first poll records the baseline and
* emits nothing, so an existing feed never floods on day one.
*
* @param array<int, mixed> $items
* @return array<int, mixed>
*/
private function filterByDate(string $automationId, string $nodeId, array $items, string $datePath): array
{
$state = AutomationNodeState::for($automationId, $nodeId);
$isFirstPoll = ! array_key_exists('last_item_date', (array) $state->data);
$watermark = $this->parseWatermark(data_get($state->data, 'last_item_date'));
$newest = null;
$new = [];
foreach ($items as $item) {
$date = $this->parseDate(data_get($item, $datePath));
if ($date === null) {
continue;
}
if ($newest === null || $date->greaterThan($newest)) {
$newest = $date;
}
// The first poll only records the baseline (the newest item's date),
// emitting nothing — so an existing feed never floods on day one, even
// if some items are dated slightly ahead of the server clock.
if (! $isFirstPoll && $date->greaterThan($watermark)) {
$new[] = $item;
}
}
if ($newest !== null) {
$state->update(['data' => array_merge($state->data ?? [], [
'last_item_date' => $newest->toIso8601String(),
])]);
}
return $new;
}
/**
* Keeps only items whose key hasn't been seen before, recording the new keys
* in a FIFO-capped per-node set. The first poll records every key but emits
* nothing (baseline), matching the date-watermark semantics so pointing the
* node at an existing feed never floods on day one.
*
* @param array<int, mixed> $items
* @return array<int, mixed>
*/
private function filterBySeenKeys(string $automationId, string $nodeId, array $items, string $keyPath): array
{
$state = AutomationNodeState::for($automationId, $nodeId);
$isFirstPoll = ! array_key_exists('seen_keys', (array) $state->data);
$hashes = array_values((array) data_get($state->data, 'seen_keys', []));
$seen = array_flip($hashes);
$new = [];
foreach ($items as $item) {
$hash = $this->keyHash($item, $keyPath);
if (isset($seen[$hash])) {
continue;
}
$seen[$hash] = true;
$hashes[] = $hash;
if (! $isFirstPoll) {
$new[] = $item;
}
}
if (count($hashes) > self::MAX_SEEN_KEYS) {
$hashes = array_slice($hashes, count($hashes) - self::MAX_SEEN_KEYS);
}
$state->update(['data' => array_merge((array) $state->data, ['seen_keys' => $hashes])]);
return $new;
}
/**
* Stable hash for an item's dedup key. Objects use `item_key_path` (falling
* back to the whole item); scalars use their own value. Hashing keeps the
* stored set compact and avoids persisting raw payloads.
*/
private function keyHash(mixed $item, string $keyPath): string
{
if (is_array($item)) {
$key = $keyPath !== '' ? data_get($item, $keyPath) : null;
// Fall back to the whole item when no usable key is present; serialize
// guards against json_encode returning false on malformed UTF-8.
$key = ($key === null || $key === '') ? (json_encode($item) ?: serialize($item)) : (string) $key;
} else {
$key = (string) $item;
}
return md5($key);
}
/**
* @param array<string, mixed> $config
* @param array<string, mixed> $context
*/
private function buildRequest(array $config, array $context): PendingRequest
{
$request = Http::asJson();
$headers = [];
foreach ((array) data_get($config, 'headers', []) as $k => $v) {
$headers[$k] = $this->resolver->resolve((string) $v, $context);
}
$authType = AuthType::tryFrom((string) data_get($config, 'auth_type', AuthType::None->value));
if ($authType === AuthType::Bearer) {
$token = $this->decrypt((string) data_get($config, 'auth_token', ''));
if ($token !== '') {
$request = $request->withToken($this->resolver->resolve($token, $context));
}
} elseif ($authType === AuthType::Basic) {
$user = (string) data_get($config, 'auth_username', '');
$pass = $this->decrypt((string) data_get($config, 'auth_password', ''));
if ($user !== '' || $pass !== '') {
$request = $request->withBasicAuth(
$this->resolver->resolve($user, $context),
$this->resolver->resolve($pass, $context),
);
}
} elseif ($authType === AuthType::ApiKey) {
$headerName = (string) data_get($config, 'auth_header_name', 'X-API-Key');
$token = $this->decrypt((string) data_get($config, 'auth_token', ''));
if ($token !== '') {
$headers[$headerName] = $this->resolver->resolve($token, $context);
}
}
if ($headers !== []) {
$request = $request->withHeaders($headers);
}
return $request->withUserAgent(config('trypost.user_agent'));
}
/**
* @param array<string, mixed> $config
* @param array<string, mixed> $context
* @return array<string, mixed>
*/
private function buildJsonBody(string $method, array $config, array $context): array
{
if (! in_array(HttpMethod::tryFrom($method), HttpMethod::withBody(), true)) {
return [];
}
$template = (string) data_get($config, 'body_template', '');
if ($template === '') {
return [];
}
// Parse the JSON body template first, then resolve placeholders in its
// string leaves so data containing quotes/newlines can't corrupt it.
$decodedTemplate = json_decode($template, true);
if (! is_array($decodedTemplate)) {
return [];
}
return $this->resolver->resolveStructured($decodedTemplate, $context);
}
/**
* @param array<int, mixed> $items
*/
private function spawnSiblings(AutomationRun $parent, string $fetchNodeId, array $items): void
{
if ($items === []) {
return;
}
// Each remaining item gets its own run that fans out across EVERY branch
// wired to this node — matching how item[0] (the current run) fans out.
$targets = $this->advance->targetsFor($parent->automation, $fetchNodeId, self::ITEM_HANDLE);
foreach ($items as $item) {
$sibling = AutomationRun::create([
'automation_id' => $parent->automation_id,
'root_run_id' => $parent->rootId(),
'trigger_item_id' => $parent->trigger_item_id,
'generated_post_id' => $parent->generated_post_id,
'is_manual' => $parent->is_manual,
'is_dry_run' => $parent->is_dry_run,
'status' => RunStatus::Pending,
'context' => array_merge($parent->context ?? [], ['fetched' => $item]),
]);
if ($targets === []) {
$sibling->update(['status' => RunStatus::Completed, 'finished_at' => now()]);
continue;
}
$this->advance->dispatchBranches($sibling, $targets);
}
}
private function decrypt(string $value): string
{
if ($value === '') {
return '';
}
try {
return Crypt::decryptString($value);
} catch (Throwable) {
// Value isn't an encrypted payload (legacy plain text or already decrypted).
return $value;
}
}
private function parseWatermark(?string $stored): CarbonImmutable
{
if ($stored === null) {
return CarbonImmutable::now();
}
try {
return CarbonImmutable::parse($stored);
} catch (Throwable) {
return CarbonImmutable::now();
}
}
private function parseDate(mixed $raw): ?CarbonImmutable
{
if (! is_string($raw) && ! is_numeric($raw)) {
return null;
}
try {
return CarbonImmutable::parse((string) $raw);
} catch (Throwable) {
return null;
}
}
}

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Node;
use App\DataTransferObjects\Automation\NodeRunResult;
use App\Enums\Automation\Publish\Mode;
use App\Enums\Post\Status as PostStatus;
use App\Jobs\PublishPost;
use App\Models\AutomationRun;
use App\Models\Post;
class RunPublishNode
{
public function __invoke(AutomationRun $run, array $config): NodeRunResult
{
$mode = Mode::from(data_get($config, 'mode', 'now'));
// Dry runs never have a generated Post (RunGenerateNode skipped
// persistence). Mirror the call site without touching the DB or
// queueing PublishPost.
if ($run->is_dry_run) {
return NodeRunResult::completed(output: [
'publish' => ['mode' => $mode->value, 'post_id' => null, 'dry_run' => true],
]);
}
$post = $run->generatedPost;
if ($post === null) {
return NodeRunResult::failed(__('automations.errors.no_generated_post'));
}
match ($mode) {
Mode::Now => $this->publishNow($post),
Mode::Scheduled => $this->schedule($post, (int) data_get($config, 'scheduled_offset')),
Mode::Draft => null,
};
return NodeRunResult::completed(output: [
'publish' => ['mode' => $mode->value, 'post_id' => $post->id],
]);
}
private function publishNow(Post $post): void
{
$post->update(['status' => PostStatus::Publishing]);
PublishPost::dispatch($post);
}
private function schedule(Post $post, int $offsetMinutes): void
{
$post->update([
'status' => PostStatus::Scheduled,
'scheduled_at' => now()->addMinutes($offsetMinutes),
]);
}
}

View file

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Node;
use App\DataTransferObjects\Automation\NodeRunResult;
use App\Enums\Automation\HttpMethod;
use App\Models\AutomationRun;
use App\Services\Automation\ExpressionResolver;
use App\Services\Brand\SafeHttpFetcher;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use Throwable;
class RunWebhookNode
{
public function __construct(
private ExpressionResolver $resolver,
private SafeHttpFetcher $safeHttp,
) {}
public function __invoke(AutomationRun $run, array $config): NodeRunResult
{
$context = $run->resolverContext();
$url = $this->resolver->resolve((string) data_get($config, 'url', ''), $context);
$method = strtoupper((string) data_get($config, 'method', HttpMethod::Post->value));
if ($url === '') {
return NodeRunResult::failed(__('automations.errors.webhook_missing_url'), [
'reason' => 'missing_url',
]);
}
try {
$this->safeHttp->guardAgainstSsrf($url);
} catch (RuntimeException) {
return NodeRunResult::failed(__('automations.errors.url_not_allowed'), [
'reason' => 'url_not_allowed',
'url' => $url,
]);
}
$headers = [];
foreach ($config['headers'] ?? [] as $k => $v) {
$headers[$k] = $this->resolver->resolve((string) $v, $context);
}
// Parse the template as JSON FIRST, then resolve placeholders in its
// string leaves — so a value containing `"`/`&`/newlines can't corrupt
// the JSON (the final json_encode escapes it).
$template = (string) data_get($config, 'payload_template', '{}');
$trimmedTemplate = trim($template);
if ($trimmedTemplate === '' || $trimmedTemplate === 'null') {
$payload = [];
} else {
$decodedTemplate = json_decode($template, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return NodeRunResult::failed(__('automations.errors.webhook_invalid_payload_json'), [
'reason' => 'invalid_payload_json',
]);
}
$payload = $this->resolver->resolveStructured($decodedTemplate ?? [], $context);
}
if ($run->is_dry_run) {
return NodeRunResult::completed(output: [
'webhook' => ['method' => $method, 'url' => $url, 'dry_run' => true],
]);
}
try {
$response = Http::withHeaders($headers)
->withUserAgent(config('trypost.user_agent'))
->send($method, $url, ['json' => $payload]);
} catch (Throwable $e) {
return NodeRunResult::failed(__('automations.errors.webhook_request_failed'), [
'reason' => 'request_failed',
'message' => $e->getMessage(),
]);
}
if ($response->serverError()) {
return NodeRunResult::failed(__('automations.errors.webhook_server_error'), [
'status' => $response->status(),
'body' => substr($response->body(), 0, 500),
]);
}
return NodeRunResult::completed(output: [
'webhook' => [
'status' => $response->status(),
'body' => substr($response->body(), 0, 500),
],
]);
}
}

View file

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Run;
use App\Enums\Automation\Run\Status;
use App\Jobs\Automation\ProcessAutomationNode;
use App\Models\Automation;
use App\Models\AutomationRun;
class AdvanceAutomationRun
{
public function __invoke(AutomationRun $run, string $fromNodeId, string $handle = 'default'): void
{
$targets = $this->targetsFor($run->automation, $fromNodeId, $handle);
if ($targets === []) {
$run->update([
'status' => Status::Completed,
'finished_at' => now(),
'current_node_id' => null,
'error' => [
'reason' => 'no_matching_edge',
'handle' => $handle,
'node_id' => $fromNodeId,
],
]);
return;
}
$this->dispatchBranches($run, $targets);
}
/**
* Every node id connected to `$fromNodeId` via the given handle. A node can
* fan out to several targets (e.g. a trigger calling RSS and HTTP at once).
*
* @return array<int, string>
*/
public function targetsFor(Automation $automation, string $fromNodeId, string $handle = 'default'): array
{
return collect($automation->connections ?? [])
->filter(fn ($c) => data_get($c, 'source') === $fromNodeId && data_get($c, 'source_handle', 'default') === $handle)
->pluck('target')
->filter()
->values()
->all();
}
/**
* Continues the run on the first branch and forks a sibling run — sharing the
* accumulated context — for every additional branch, so all targets execute.
*
* @param array<int, string> $targets
*/
public function dispatchBranches(AutomationRun $run, array $targets): void
{
$first = array_shift($targets);
foreach ($targets as $target) {
$sibling = AutomationRun::create([
'automation_id' => $run->automation_id,
'root_run_id' => $run->rootId(),
'trigger_item_id' => $run->trigger_item_id,
'generated_post_id' => $run->generated_post_id,
'is_manual' => $run->is_manual,
'is_dry_run' => $run->is_dry_run,
'status' => Status::Pending,
'context' => $run->context,
]);
ProcessAutomationNode::dispatch($sibling, $target);
}
ProcessAutomationNode::dispatch($run, $first);
}
}

View file

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Run;
use App\Enums\Automation\Run\Status;
use App\Models\Automation;
use App\Models\AutomationRun;
use App\Models\AutomationTriggerItem;
class DispatchAutomationRun
{
public function __construct(private AdvanceAutomationRun $advance) {}
public function __invoke(Automation $automation, AutomationTriggerItem $triggerItem): AutomationRun
{
$targets = $this->triggerTargets($automation);
$run = AutomationRun::create([
'automation_id' => $automation->id,
'trigger_item_id' => $triggerItem->id,
'status' => Status::Pending,
'context' => ['trigger' => $triggerItem->payload],
]);
if ($targets === []) {
$run->update([
'status' => Status::Failed,
'error' => ['message' => __('automations.errors.no_trigger_connection')],
'finished_at' => now(),
]);
return $run;
}
$this->advance->dispatchBranches($run, $targets);
return $run;
}
/**
* @return array<int, string>
*/
private function triggerTargets(Automation $automation): array
{
$triggerNode = collect($automation->nodes ?? [])->firstWhere('type', 'trigger');
if ($triggerNode === null) {
return [];
}
return $this->advance->targetsFor($automation, $triggerNode['id']);
}
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Run;
use App\Enums\Automation\Run\Status;
use App\Jobs\Automation\ProcessAutomationNode;
use App\Models\AutomationRun;
use DomainException;
class RetryRunFromNode
{
public function __invoke(AutomationRun $run, string $nodeId): void
{
if ($run->status !== Status::Failed) {
throw new DomainException(__('automations.errors.only_failed_can_retry'));
}
$run->update([
'status' => Status::Pending,
'error' => null,
'finished_at' => null,
]);
ProcessAutomationNode::dispatch($run, $nodeId);
}
}

View file

@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Run;
use App\Enums\Automation\Run\Status;
use App\Enums\Automation\Trigger\Type as TriggerType;
use App\Models\Automation;
use App\Models\AutomationRun;
use App\Models\Post;
use App\Services\Automation\AutomationConfigValidator;
use DomainException;
/**
* Kicks off a manual run from the editor without waiting for the real trigger
* to fire. Mirrors n8n's "Execute workflow" — the same job pipeline runs, but
* the trigger payload is synthesized so the user can see exactly what each
* node does end-to-end. Runs are flagged `is_manual=true` so they're filtered
* from production audit views.
*
* When `$withRealData` is false (the default), the run is marked `is_dry_run`
* so side-effectful nodes (publish, generate, watermark advancement, sibling
* spawning) short-circuit. Dry-run rows are kept briefly so the editor test
* panel can show the completed result, then reaped by the scheduled
* `automation:prune-dry-runs` command once past its grace window.
*/
class TestAutomation
{
public function __construct(
private AdvanceAutomationRun $advance,
private AutomationConfigValidator $configValidator,
) {}
public function __invoke(Automation $automation, bool $withRealData = false): AutomationRun
{
$issue = $this->configValidator->firstMessage($automation->nodes ?? []);
if ($issue !== null) {
throw new DomainException($issue);
}
$triggerNode = collect($automation->nodes ?? [])->firstWhere('type', 'trigger');
$context = ['trigger' => $this->synthesizePayload($automation, $triggerNode ?? [])];
$targets = $triggerNode !== null
? $this->advance->targetsFor($automation, $triggerNode['id'])
: [];
$run = AutomationRun::create([
'automation_id' => $automation->id,
'status' => Status::Pending,
'is_manual' => true,
'is_dry_run' => ! $withRealData,
'context' => $context,
]);
if ($targets === []) {
$run->update([
'status' => Status::Failed,
'error' => ['message' => __('automations.errors.no_trigger_connection')],
'finished_at' => now(),
]);
return $run;
}
$this->advance->dispatchBranches($run, $targets);
return $run;
}
/**
* @param array<string, mixed> $triggerNode
* @return array<string, mixed>
*/
private function synthesizePayload(Automation $automation, array $triggerNode): array
{
$type = data_get($triggerNode, 'data.trigger_type');
return match ($type) {
TriggerType::PostPublished->value, TriggerType::PostScheduled->value => $this->synthesizePostPayload($automation, (string) $type),
default => ['event' => $type ?? TriggerType::Schedule->value, 'fired_at' => now()->toIso8601String(), 'manual' => true],
};
}
/**
* Picks the most recent post in the automation's workspace so the test run
* reflects something the user actually sees. Falls back to a placeholder
* payload when the workspace has no posts yet.
*
* @return array<string, mixed>
*/
private function synthesizePostPayload(Automation $automation, string $event): array
{
$post = Post::query()
->where('workspace_id', $automation->workspace_id)
->latest()
->first();
$base = [
'event' => $event,
'fired_at' => now()->toIso8601String(),
'manual' => true,
];
if ($post === null) {
return array_merge($base, ['post' => null, 'fetch_error' => 'no posts in workspace']);
}
return array_merge($base, [
'post' => [
'id' => $post->id,
'content' => $post->content,
'status' => $post->status->value,
'scheduled_at' => $post->scheduled_at?->toIso8601String(),
'published_at' => $post->published_at?->toIso8601String(),
],
]);
}
}

View file

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Trigger;
use App\Actions\Automation\Run\AdvanceAutomationRun;
use App\Enums\Automation\Node\Type as NodeType;
use App\Enums\Automation\Run\Status as RunStatus;
use App\Enums\Automation\Status as AutomationStatus;
use App\Enums\Automation\Trigger\Type as TriggerType;
use App\Models\Automation;
use App\Models\AutomationRun;
use App\Models\Post;
/**
* Walks the workspace's active automations and dispatches a run for each one
* whose Trigger node matches the given post-related event (PostPublished /
* PostScheduled). The post payload is placed at `context.trigger.post` so
* downstream nodes can reference it via templates like `{{ trigger.post.id }}`.
*
* V1 limitation: every post fires triggers, regardless of whether the post
* itself was created by an automation. If a future use case introduces loops
* (automation X publishes → trigger fires → X publishes again), we'll need a
* `posts.created_by_automation_run_id` column to skip them.
*/
class DispatchPostTriggerAutomations
{
public function __construct(private AdvanceAutomationRun $advance) {}
public function __invoke(Post $post, TriggerType $triggerType): void
{
$automations = Automation::query()
->where('workspace_id', $post->workspace_id)
->where('status', AutomationStatus::Active)
->where('trigger_type', $triggerType->value)
->get();
foreach ($automations as $automation) {
$triggerNode = collect($automation->nodes ?? [])->firstWhere('type', NodeType::Trigger->value);
if ($triggerNode === null) {
continue;
}
$this->dispatchRun($automation, $triggerNode, $post);
}
}
private function dispatchRun(Automation $automation, array $triggerNode, Post $post): void
{
$context = [
'trigger' => [
'event' => $triggerNode['data']['trigger_type'],
'fired_at' => now()->toIso8601String(),
'post' => [
'id' => $post->id,
'content' => $post->content,
'status' => $post->status->value,
'scheduled_at' => $post->scheduled_at?->toIso8601String(),
'published_at' => $post->published_at?->toIso8601String(),
],
],
];
$run = AutomationRun::create([
'automation_id' => $automation->id,
'status' => RunStatus::Pending,
'context' => $context,
]);
$targets = $this->advance->targetsFor($automation, $triggerNode['id']);
if ($targets === []) {
$run->update([
'status' => RunStatus::Failed,
'error' => ['message' => __('automations.errors.no_trigger_connection')],
'finished_at' => now(),
]);
return;
}
$this->advance->dispatchBranches($run, $targets);
}
}

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\Trigger;
use App\Actions\Automation\TriggerItem\EnrollTriggerItem;
use App\Models\Automation;
use Cron\CronExpression;
class FireScheduleTrigger
{
public function __construct(private EnrollTriggerItem $enroll) {}
public function __invoke(Automation $automation): bool
{
$triggerNode = collect($automation->nodes ?? [])->firstWhere('type', 'trigger');
$cron = data_get($triggerNode, 'data.cron');
$timezone = data_get($triggerNode, 'data.schedule_timezone', config('app.timezone'));
if ($cron === null) {
return false;
}
$expression = new CronExpression($cron);
if (! $expression->isDue(now(), $timezone)) {
return false;
}
$key = now()->format('Y-m-d\TH:i');
$payload = ['fired_at' => now()->toIso8601String()];
return ($this->enroll)($automation, $key, $payload) !== null;
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Actions\Automation\TriggerItem;
use App\Actions\Automation\Run\DispatchAutomationRun;
use App\Models\Automation;
use App\Models\AutomationRun;
use App\Models\AutomationTriggerItem;
class EnrollTriggerItem
{
public function __construct(private DispatchAutomationRun $dispatchRun) {}
public function __invoke(Automation $automation, string $itemKey, array $payload): ?AutomationRun
{
$existing = AutomationTriggerItem::where('automation_id', $automation->id)
->where('item_key', $itemKey)
->first();
if ($existing !== null) {
return null;
}
$item = AutomationTriggerItem::create([
'automation_id' => $automation->id,
'item_key' => $itemKey,
'payload' => $payload,
'first_seen_at' => now(),
]);
return ($this->dispatchRun)($automation, $item);
}
}

View file

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

View file

@ -19,8 +19,7 @@ public static function execute(User $user, array $data): Workspace
'name' => data_get($data, 'name'),
'brand_website' => data_get($data, 'brand_website'),
'brand_description' => data_get($data, 'brand_description'),
'brand_tone' => data_get($data, 'brand_tone'),
'brand_voice_notes' => data_get($data, 'brand_voice_notes'),
'brand_voice_traits' => data_get($data, 'brand_voice_traits'),
'brand_color' => data_get($data, 'brand_color'),
'background_color' => data_get($data, 'background_color'),
'text_color' => data_get($data, 'text_color'),

View file

@ -4,6 +4,7 @@
namespace App\Ai\Agents;
use App\Enums\Workspace\BrandVoiceTrait;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
@ -16,7 +17,10 @@ class BrandAnalyzer implements Agent, HasStructuredOutput
public function instructions(): string
{
return view('prompts.brand_analyzer')->render();
return view('prompts.brand_analyzer', [
'voice_groups' => BrandVoiceTrait::grouped(),
'single_select_groups' => BrandVoiceTrait::singleSelectGroups(),
])->render();
}
public function provider(): Lab
@ -42,17 +46,10 @@ public function schema(JsonSchema $schema): array
'description' => $schema->string()
->description('A concise 2-3 sentence brand description summarizing what the company does, who they serve, and what makes them unique. Written in the detected content language.')
->required(),
'tone' => $schema->string()
->enum(['professional', 'casual', 'friendly', 'bold', 'inspirational', 'humorous', 'educational'])
->description('The tone of voice the brand uses in their content.')
->required(),
'language' => $schema->string()
->enum(['en', 'pt-BR', 'es'])
->description('The primary language of the content.')
->required(),
'voice_notes' => $schema->string()
->description('2-3 sentences of concrete writing guidelines inferred from the site style (e.g. "Use technical but approachable language", "Avoid marketing buzzwords"). Written in the detected content language.')
->required(),
'brand_color' => $schema->string()
->description('The primary brand color as a hex string starting with # (e.g. "#0ea5e9"). Pick the most prominent accent color used in CTAs, links, or logos. Return empty string if not confidently identifiable.')
->required(),
@ -62,6 +59,10 @@ public function schema(JsonSchema $schema): array
'text_color' => $schema->string()
->description('The dominant body text color as a hex string starting with # (e.g. "#0f172a"). Return empty string if not confidently identifiable.')
->required(),
'voice_traits' => $schema->array()
->items($schema->string()->enum(BrandVoiceTrait::values()))
->description('Brand voice traits inferred from the site, using only the allowed values. Pick AT MOST ONE per single-select dimension (point of view, formality, energy, humor, attitude, warmth, confidence) and any number of style traits. See the instructions for the groups and what each value means.')
->required(),
];
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Ai\Agents\Concerns;
use App\Enums\PostPlatform\ContentType;
trait ResolvesPlatformCopyBudget
{
/**
* Resolve the per-platform copy budget for a content-type string so every
* agent that writes OR rewrites post text honours the same character caps.
* Returns nulls when the platform is unknown, leaving the prompt
* platform-agnostic.
*
* @return array{hard_max_chars: ?int, target_chars: ?int, platform_label: ?string}
*/
protected function platformCopyBudget(?string $platformContext): array
{
$contentType = $platformContext !== null ? ContentType::tryFrom($platformContext) : null;
if (! $contentType instanceof ContentType) {
return ['hard_max_chars' => null, 'target_chars' => null, 'platform_label' => null];
}
$platform = $contentType->platform();
return [
'hard_max_chars' => $platform->maxContentLength(),
'target_chars' => $platform->recommendedAiContentLength(),
'platform_label' => $platform->label(),
];
}
}

View file

@ -4,7 +4,7 @@
namespace App\Ai\Agents;
use App\Enums\PostPlatform\ContentType;
use App\Ai\Agents\Concerns\ResolvesPlatformCopyBudget;
use App\Models\Workspace;
use App\Services\Ai\TemplateContextResolver;
use Illuminate\Contracts\JsonSchema\JsonSchema;
@ -18,6 +18,7 @@
class PostContentGenerator implements Agent, HasStructuredOutput
{
use Promptable;
use ResolvesPlatformCopyBudget;
public function __construct(
public Workspace $workspace,
@ -25,6 +26,7 @@ public function __construct(
public string $format = 'single',
public int $slideCount = 1,
public ?string $platformContext = null,
public bool $applyBrandVoice = true,
) {}
public function instructions(): string
@ -46,30 +48,20 @@ public function instructions(): string
// Two budgets: the platform's HARD cap (must never exceed — would break
// publishing) and the recommended SWEET SPOT length (what well-performing
// posts actually look like, way below the hard cap on most platforms).
$hardMaxChars = null;
$targetChars = null;
$platformLabel = null;
$contentType = $this->platformContext ? ContentType::tryFrom($this->platformContext) : null;
if ($contentType) {
$platform = $contentType->platform();
$hardMaxChars = $platform->maxContentLength();
$targetChars = $platform->recommendedAiContentLength();
$platformLabel = $platform->label();
}
$budget = $this->platformCopyBudget($this->platformContext);
return view('prompts.post_content.generator', [
'brand_name' => $this->workspace->name ?? '',
'brand_description' => $this->workspace->brand_description ?? '',
'brand_tone' => $this->workspace->brand_tone ?? '',
'brand_voice_notes' => $this->workspace->brand_voice_notes ?? '',
'brand_description' => $this->applyBrandVoice ? ($this->workspace->brand_description ?? '') : '',
'brand_voice_traits' => $this->applyBrandVoice ? ($this->workspace->brand_voice_traits ?? []) : [],
'content_language' => $this->workspace->content_language,
'current_content' => $this->currentContent,
'format' => $this->format,
'slide_count' => $this->slideCount,
'examples' => $examples,
'hard_max_chars' => $hardMaxChars,
'target_chars' => $targetChars,
'platform_label' => $platformLabel,
'hard_max_chars' => $budget['hard_max_chars'],
'target_chars' => $budget['target_chars'],
'platform_label' => $budget['platform_label'],
])->render();
}

View file

@ -4,6 +4,7 @@
namespace App\Ai\Agents;
use App\Ai\Agents\Concerns\ResolvesPlatformCopyBudget;
use App\Models\Workspace;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Attributes\Temperature;
@ -22,20 +23,30 @@
class PostContentHumanizer implements Agent, HasStructuredOutput
{
use Promptable;
use ResolvesPlatformCopyBudget;
public function __construct(
public Workspace $workspace,
public string $format = 'single',
public ?string $platformContext = null,
public bool $applyBrandVoice = true,
) {}
public function instructions(): string
{
// The generator already wrote within the platform's character cap; the
// humanizer must preserve it, so it gets the same budget — otherwise the
// rewrite can drift past the limit the generator respected.
$budget = $this->platformCopyBudget($this->platformContext);
return view('prompts.post_content.humanizer', [
'brand_name' => $this->workspace->name ?? '',
'brand_tone' => $this->workspace->brand_tone ?? '',
'brand_voice_notes' => $this->workspace->brand_voice_notes ?? '',
'brand_voice_traits' => $this->applyBrandVoice ? ($this->workspace->brand_voice_traits ?? []) : [],
'content_language' => $this->workspace->content_language,
'format' => $this->format,
'hard_max_chars' => $budget['hard_max_chars'],
'target_chars' => $budget['target_chars'],
'platform_label' => $budget['platform_label'],
])->render();
}

View file

@ -25,8 +25,7 @@ public function instructions(): string
{
return view('prompts.post_content.reviewer', [
'brand_name' => $this->workspace->name ?? '',
'brand_tone' => $this->workspace->brand_tone ?? '',
'brand_voice_notes' => $this->workspace->brand_voice_notes ?? '',
'brand_voice_traits' => $this->workspace->brand_voice_traits ?? [],
'content_language' => $this->workspace->content_language,
])->render();
}

View file

@ -33,8 +33,7 @@ public function instructions(): string
return view('prompts.post_content.generator', [
'brand_name' => $this->workspace->name ?? '',
'brand_description' => $this->workspace->brand_description ?? '',
'brand_tone' => $this->workspace->brand_tone ?? '',
'brand_voice_notes' => $this->workspace->brand_voice_notes ?? '',
'brand_voice_traits' => $this->workspace->brand_voice_traits ?? [],
'content_language' => $this->workspace->content_language,
'current_content' => $this->currentContent,
'format' => 'single',

View file

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

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Automation;
use App\Actions\Automation\Trigger\FireScheduleTrigger;
use App\Enums\Automation\Status;
use App\Enums\Automation\Trigger\Type as TriggerType;
use App\Models\Automation;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
#[Signature('automation:fire-schedule')]
#[Description('Fire scheduled automations whose cron matches now')]
class FireScheduleTriggers extends Command
{
public function handle(FireScheduleTrigger $fire): int
{
Automation::query()
->where('status', Status::Active)
->where('trigger_type', TriggerType::Schedule->value)
->chunkById(50, function ($automations) use ($fire) {
foreach ($automations as $automation) {
$fire($automation);
}
});
return self::SUCCESS;
}
}

View file

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

View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Automation;
use App\Models\AutomationRun;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
#[Signature('automation:prune-dry-runs')]
#[Description('Delete terminal dry-run automation runs older than the test-panel grace window')]
class PruneDryRunAutomationRuns extends Command
{
private const GRACE_MINUTES = 10;
public function handle(): int
{
$count = AutomationRun::query()
->where('is_dry_run', true)
->whereNotNull('finished_at')
->where('finished_at', '<=', now()->subMinutes(self::GRACE_MINUTES))
->delete();
$this->info("Pruned {$count} dry-run automation runs.");
return self::SUCCESS;
}
}

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Automation;
use App\Enums\Automation\Run\Status;
use App\Models\AutomationRun;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
#[Signature('automation:recover-stuck-runs')]
#[Description('Fail automation runs stuck running or pending for more than 1 hour')]
class RecoverStuckAutomationRuns extends Command
{
public function handle(): int
{
$count = AutomationRun::query()
->whereIn('status', [Status::Running, Status::Pending])
->where('updated_at', '<=', now()->subHour())
->update([
'status' => Status::Failed,
'error' => ['reason' => 'stuck'],
'finished_at' => now(),
]);
$this->info("Recovered {$count} stuck automation runs.");
return self::SUCCESS;
}
}

View file

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

View file

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Enums\Automation;
/**
* Authentication strategies for the HTTP Request node. Mirrors the frontend
* AuthType const (resources/js/types/automation/auth-type.ts).
*/
enum AuthType: string
{
case None = 'none';
case Bearer = 'bearer';
case Basic = 'basic';
case ApiKey = 'api_key';
}

View file

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Enums\Automation\Condition;
/**
* Output handles of a Condition node. The matched branch determines which
* handle the run continues down — these values must mirror the `id`s of the
* handles rendered in the frontend ConditionNode (resources/js/types/automation
* /condition-handle.ts) and the `source_handle` stored on edges.
*/
enum Handle: string
{
case Yes = 'yes';
case No = 'no';
}

View file

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

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Enums\Automation;
/**
* Time units for the Delay node. Mirrors the frontend DelayUnit const
* (resources/js/types/automation/delay-unit.ts).
*/
enum DelayUnit: string
{
case Minutes = 'minutes';
case Hours = 'hours';
case Days = 'days';
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Enums\Automation;
/**
* HTTP verbs available to the HTTP Request and Webhook nodes. Mirrors the
* frontend HttpMethod const (resources/js/types/automation/http-method.ts).
*/
enum HttpMethod: string
{
case Get = 'GET';
case Post = 'POST';
case Put = 'PUT';
case Patch = 'PATCH';
case Delete = 'DELETE';
/**
* Verbs that carry a request body.
*
* @return array<int, self>
*/
public static function withBody(): array
{
return [self::Post, self::Put, self::Patch];
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Enums\Automation;
/**
* Schedule interval units for a Schedule trigger. Mirrors the frontend
* ScheduleField const (resources/js/types/automation/schedule-field.ts).
*/
enum ScheduleField: string
{
case Minutes = 'minutes';
case Hours = 'hours';
case Days = 'days';
case Weeks = 'weeks';
case Months = 'months';
}

View file

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

View file

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

View file

@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace App\Enums\Workspace;
/**
* Discrete brand-voice traits a workspace can toggle, replacing the old
* free-text `brand_voice_notes` AND the single `brand_tone` column. Stored as a
* JSON array of these string values (e.g. ["third_person", "balanced", "no_hype"]).
*
* Traits are organized into groups so any selection composes coherently:
* - the spectrum groups (pov, formality, energy, humor, attitude, warmth,
* confidence) are SINGLE-SELECT in the UI — mutually exclusive opposites,
* - the `style` group stacks freely (additive traits with no opposite).
*
* The instruction each trait maps to lives in the prompt Blade
* (`prompts/post_content/_voice`), never here — see the project AI conventions.
*/
enum BrandVoiceTrait: string
{
// Point of view.
case FirstPerson = 'first_person';
case SecondPerson = 'second_person';
case ThirdPerson = 'third_person';
// Formality.
case Formal = 'formal';
case Balanced = 'balanced';
case Casual = 'casual';
// Energy.
case Calm = 'calm';
case Moderate = 'moderate';
case Enthusiastic = 'enthusiastic';
case Vibrant = 'vibrant';
// Humor.
case Serious = 'serious';
case Dry = 'dry';
case Witty = 'witty';
case Playful = 'playful';
// Attitude.
case Respectful = 'respectful';
case EvenHanded = 'even_handed';
case Bold = 'bold';
case Provocative = 'provocative';
// Warmth.
case Neutral = 'neutral';
case Friendly = 'friendly';
case Empathetic = 'empathetic';
// Confidence.
case Humble = 'humble';
case Confident = 'confident';
case Assertive = 'assertive';
// Additive style traits (stack freely).
case Direct = 'direct';
case Concise = 'concise';
case Transparent = 'transparent';
case NoHype = 'no_hype';
case Practical = 'practical';
case DataDriven = 'data_driven';
case Storytelling = 'storytelling';
case Inspirational = 'inspirational';
case Educational = 'educational';
case Technical = 'technical';
case Minimalist = 'minimalist';
/**
* UI grouping. Every group except `style` is single-select (mutually
* exclusive opposites); `style` traits stack.
*/
public function group(): string
{
return match ($this) {
self::FirstPerson, self::SecondPerson, self::ThirdPerson => 'pov',
self::Formal, self::Balanced, self::Casual => 'formality',
self::Calm, self::Moderate, self::Enthusiastic, self::Vibrant => 'energy',
self::Serious, self::Dry, self::Witty, self::Playful => 'humor',
self::Respectful, self::EvenHanded, self::Bold, self::Provocative => 'attitude',
self::Neutral, self::Friendly, self::Empathetic => 'warmth',
self::Humble, self::Confident, self::Assertive => 'confidence',
default => 'style',
};
}
/**
* Groups whose options are mutually exclusive (rendered single-select).
*
* @return array<int, string>
*/
public static function singleSelectGroups(): array
{
return ['pov', 'formality', 'energy', 'humor', 'attitude', 'warmth', 'confidence'];
}
/**
* @return array<int, string>
*/
public static function values(): array
{
return array_map(fn (self $t) => $t->value, self::cases());
}
/**
* Reduces arbitrary values (e.g. an LLM's guess) to a coherent selection:
* only valid traits, and at most one per single-select dimension. Style
* traits stack. Order is preserved.
*
* @param array<int, mixed> $values
* @return array<int, string>
*/
public static function coerce(array $values): array
{
$single = self::singleSelectGroups();
$usedGroups = [];
$result = [];
foreach ($values as $value) {
$trait = is_string($value) ? self::tryFrom($value) : null;
if ($trait === null) {
continue;
}
$group = $trait->group();
if (in_array($group, $single, true)) {
if (isset($usedGroups[$group])) {
continue;
}
$usedGroups[$group] = true;
}
$result[] = $trait->value;
}
return $result;
}
/**
* Trait values keyed by group, in declaration order — drives the brand
* form's grouped pill picker.
*
* @return array<string, array<int, string>>
*/
public static function grouped(): array
{
$grouped = [];
foreach (self::cases() as $trait) {
$grouped[$trait->group()][] = $trait->value;
}
return $grouped;
}
}

View file

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

View file

@ -0,0 +1,228 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\App;
use App\Actions\Automation\Automation\ActivateAutomation;
use App\Actions\Automation\Automation\CreateAutomation;
use App\Actions\Automation\Automation\DeleteAutomation;
use App\Actions\Automation\Automation\GetAutomationEditorData;
use App\Actions\Automation\Automation\GetAutomationInvocations;
use App\Actions\Automation\Automation\GetAutomationMetrics;
use App\Actions\Automation\Automation\ListAutomations;
use App\Actions\Automation\Automation\PauseAutomation;
use App\Actions\Automation\Automation\UpdateAutomation;
use App\Actions\Automation\Run\RetryRunFromNode;
use App\Actions\Automation\Run\TestAutomation;
use App\Http\Controllers\Controller;
use App\Http\Requests\App\Automations\ActivateAutomationRequest;
use App\Http\Requests\App\Automations\PauseAutomationRequest;
use App\Http\Requests\App\Automations\RetryRunRequest;
use App\Http\Requests\App\Automations\StoreAutomationRequest;
use App\Http\Requests\App\Automations\TestAutomationRequest;
use App\Http\Requests\App\Automations\UpdateAutomationRequest;
use App\Http\Resources\App\PlatformConfigResource;
use App\Http\Resources\App\SocialAccountResource;
use App\Http\Resources\AutomationInvocationResource;
use App\Http\Resources\AutomationNodeRunResource;
use App\Http\Resources\AutomationResource;
use App\Http\Resources\AutomationRunResource;
use App\Models\Automation;
use App\Models\AutomationNodeRun;
use App\Models\AutomationRun;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Response as HttpResponse;
use Inertia\Inertia;
use Inertia\Response;
class AutomationController extends Controller
{
public function index(ListAutomations $list): Response
{
$workspace = request()->user()->currentWorkspace;
$automations = Inertia::scroll(fn () => AutomationResource::collection(
$list($workspace)
));
return Inertia::render('automations/Index', [
'automations' => $automations,
]);
}
public function store(StoreAutomationRequest $request, CreateAutomation $create): RedirectResponse
{
$automation = $create(
$request->user()->currentWorkspace,
$request->user(),
);
return redirect()->route('app.automations.workflow', $automation->id);
}
public function show(Automation $automation): RedirectResponse
{
return redirect()->route('app.automations.workflow', $automation->id);
}
public function workflow(Automation $automation, GetAutomationEditorData $editorData): Response
{
$this->authorize('update', $automation);
['socialAccounts' => $socialAccounts, 'pinterestBoards' => $pinterestBoards, 'tiktokCreatorInfos' => $tiktokCreatorInfos] = $editorData($automation);
$platformConfigs = $socialAccounts->mapWithKeys(fn ($account) => [
$account->id => new PlatformConfigResource($account),
]);
return Inertia::render('automations/Form', [
'automation' => AutomationResource::make($automation),
'socialAccounts' => SocialAccountResource::collection($socialAccounts),
'platformConfigs' => $platformConfigs,
'pinterestBoards' => $pinterestBoards,
'tiktokCreatorInfos' => $tiktokCreatorInfos,
]);
}
public function invocations(Automation $automation, GetAutomationInvocations $invocations): Response
{
$this->authorize('view', $automation);
$status = request()->string('status')->toString() ?: null;
$search = request()->string('search')->toString() ?: null;
return Inertia::render('automations/Invocations', [
'automation' => AutomationResource::make($automation),
'invocations' => Inertia::scroll(fn () => AutomationInvocationResource::collection(
$invocations($automation, $status, $search)
)),
'filters' => [
'status' => $status,
'search' => $search,
],
]);
}
public function settings(Automation $automation): Response
{
$this->authorize('view', $automation);
return Inertia::render('automations/Settings', [
'automation' => AutomationResource::make($automation),
]);
}
public function metrics(Automation $automation, GetAutomationMetrics $metrics): Response
{
$this->authorize('view', $automation);
$end = (request()->date('end') ?? now())->startOfDay();
$start = (request()->date('start') ?? now()->subDays(6))->startOfDay();
if ($start->greaterThan($end)) {
[$start, $end] = [$end, $start];
}
// Cap the window so a hand-edited URL can't request a multi-year, daily
// bucketed series.
if ($start->diffInDays($end) > 366) {
$start = $end->copy()->subDays(366);
}
return Inertia::render('automations/Metrics', [
'automation' => AutomationResource::make($automation),
'metrics' => $metrics($automation, $start, $end),
'filters' => [
'start' => $start->toDateString(),
'end' => $end->toDateString(),
],
]);
}
public function update(UpdateAutomationRequest $request, Automation $automation, UpdateAutomation $update): RedirectResponse
{
$this->authorize('update', $automation);
$update($automation, $request->validated());
return back();
}
public function destroy(Automation $automation, DeleteAutomation $delete): RedirectResponse
{
$this->authorize('delete', $automation);
$delete($automation);
session()->flash('flash.banner', __('automations.flash.deleted'));
session()->flash('flash.bannerStyle', 'success');
return redirect()->route('app.automations.index');
}
public function activate(ActivateAutomationRequest $request, Automation $automation, ActivateAutomation $activate): RedirectResponse
{
$this->authorize('activate', $automation);
$activate($automation);
return back();
}
public function pause(PauseAutomationRequest $request, Automation $automation, PauseAutomation $pause): RedirectResponse
{
$this->authorize('pause', $automation);
$pause($automation);
return back();
}
public function retryRun(
RetryRunRequest $request,
RetryRunFromNode $retry,
Automation $automation,
AutomationRun $run,
): HttpResponse {
$this->authorize('update', $automation);
abort_unless($run->automation_id === $automation->id, 404);
$nodeId = $request->validated('node_id') ?? $run->current_node_id;
$retry($run, $nodeId);
return response()->noContent();
}
public function test(TestAutomationRequest $request, Automation $automation, TestAutomation $test): JsonResponse
{
$this->authorize('update', $automation);
$run = $test($automation, (bool) $request->validated('with_real_data', false));
return response()->json(['run_id' => $run->id]);
}
public function showRun(Automation $automation, AutomationRun $run): JsonResponse
{
$this->authorize('view', $automation);
abort_unless($run->automation_id === $automation->id, 404);
// Aggregate the node runs of every branch forked by a fan-out so the test
// panel shows the whole execution, not just the branch the root walked.
$rootId = $run->rootId();
$nodeRuns = AutomationNodeRun::query()
->whereHas('run', fn ($query) => $query
->where('id', $rootId)
->orWhere('root_run_id', $rootId))
->orderBy('started_at')
->orderBy('id')
->get();
return response()->json([
'run' => AutomationRunResource::make($run)->resolve(),
'node_runs' => AutomationNodeRunResource::collection($nodeRuns)->resolve(),
]);
}
}

View file

@ -8,6 +8,7 @@
use App\Actions\Workspace\CreateWorkspace;
use App\Actions\Workspace\DeleteWorkspace;
use App\Enums\Workspace\BrandFont;
use App\Enums\Workspace\BrandVoiceTrait;
use App\Enums\Workspace\ImageStyle;
use App\Http\Requests\App\Workspace\StoreWorkspaceRequest;
use App\Http\Requests\App\Workspace\UpdateWorkspaceRequest;
@ -81,6 +82,7 @@ public function create(Request $request): Response|RedirectResponse
return Inertia::render('workspaces/Create', [
'availableFonts' => BrandFont::values(),
'availableImageStyles' => ImageStyle::values(),
'availableVoiceTraits' => BrandVoiceTrait::grouped(),
]);
}
@ -178,6 +180,7 @@ public function brandSettings(Request $request): Response|RedirectResponse
'workspace' => $workspace,
'availableFonts' => BrandFont::values(),
'availableImageStyles' => ImageStyle::values(),
'availableVoiceTraits' => BrandVoiceTrait::grouped(),
]);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\App\Automations;
use App\Enums\Automation\AuthType;
use App\Enums\Automation\Condition\Operator as ConditionOperator;
use App\Enums\Automation\DelayUnit;
use App\Enums\Automation\HttpMethod;
use App\Enums\Automation\Node\Type as NodeType;
use App\Enums\Automation\Publish\Mode as PublishMode;
use App\Enums\Automation\ScheduleField;
use App\Enums\Automation\Trigger\Type as TriggerType;
use App\Services\Automation\AutomationConfigValidator;
use App\Services\Automation\GenerateNodeValidator;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateAutomationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$rules = [
'name' => ['sometimes', 'string', 'max:120'],
'nodes' => ['sometimes', 'array'],
'nodes.*.id' => ['required', 'string'],
'nodes.*.type' => ['required', 'string', Rule::in(array_column(NodeType::cases(), 'value'))],
'nodes.*.position' => ['required', 'array'],
'nodes.*.position.x' => ['required', 'numeric'],
'nodes.*.position.y' => ['required', 'numeric'],
'nodes.*.data' => ['required', 'array'],
'connections' => ['sometimes', 'array'],
'connections.*.id' => ['required', 'string'],
'connections.*.source' => ['required', 'string'],
'connections.*.target' => ['required', 'string'],
'connections.*.source_handle' => ['nullable', 'string'],
'connections.*.target_handle' => ['nullable', 'string'],
'variables' => ['sometimes', 'array', 'max:50'],
'variables.*.key' => ['required', 'string', 'max:60', 'regex:/^[A-Za-z_][A-Za-z0-9_]*$/', 'distinct'],
'variables.*.value' => ['nullable', 'string'],
];
// Per-node data validation. We build these dynamically so each node's
// type drives the shape of its `data` payload, and so errors come back
// with full paths like `nodes.2.data.feed_url` for the frontend to map.
$nodes = $this->input('nodes', []);
if (is_array($nodes)) {
foreach ($nodes as $i => $node) {
$type = data_get($node, 'type');
foreach ($this->dataRulesForNodeType($type, (int) $i) as $field => $fieldRules) {
$rules["nodes.{$i}.data.{$field}"] = $fieldRules;
}
}
}
return $rules;
}
/**
* Block saving a node whose config can't run: a Generate node whose image
* count doesn't fit a selected account's content-type, or a Webhook node
* whose payload template isn't valid JSON. Each issue is keyed to the field
* the frontend surfaces it under (mirrors the inline frontend validation).
*/
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
$nodes = $this->input('nodes', []);
if (! is_array($nodes)) {
return;
}
foreach (app(AutomationConfigValidator::class)->issues($nodes) as $issue) {
$validator->errors()->add("nodes.{$issue['node_index']}.data.{$issue['field']}", $issue['message']);
}
});
}
/**
* @return array<string, mixed>
*/
public function attributes(): array
{
return [
'nodes.*.data.feed_url' => 'Feed URL',
'nodes.*.data.url' => 'URL',
'nodes.*.data.cron' => 'cron expression',
'nodes.*.data.duration' => 'duration',
'nodes.*.data.unit' => 'unit',
'nodes.*.data.field' => 'field',
'nodes.*.data.operator' => 'operator',
'nodes.*.data.mode' => 'mode',
'nodes.*.data.method' => 'method',
'nodes.*.data.trigger_type' => 'trigger type',
'nodes.*.data.prompt_template' => 'prompt template',
'nodes.*.data.accounts' => 'accounts',
];
}
/**
* @return array<string, array<int, mixed>>
*/
private function dataRulesForNodeType(?string $type, int $i): array
{
return match ($type) {
NodeType::Trigger->value => [
'trigger_type' => ['required', Rule::in(array_column(TriggerType::cases(), 'value'))],
'cron' => ['required_if:nodes.'.$i.'.data.trigger_type,'.TriggerType::Schedule->value, 'string'],
'schedule_field' => ['sometimes', Rule::in(array_column(ScheduleField::cases(), 'value'))],
'schedule_minutes_interval' => ['sometimes', 'integer', 'min:1', 'max:59'],
'schedule_hours_interval' => ['sometimes', 'integer', 'min:1', 'max:23'],
'schedule_days_interval' => ['sometimes', 'integer', 'min:1', 'max:31'],
'schedule_hour' => ['sometimes', 'integer', 'min:0', 'max:23'],
'schedule_minute' => ['sometimes', 'integer', 'min:0', 'max:59'],
'schedule_weekdays' => ['sometimes', 'array'],
'schedule_weekdays.*' => ['integer', 'min:0', 'max:6'],
'schedule_day_of_month' => ['sometimes', 'integer', 'min:1', 'max:31'],
'schedule_timezone' => ['sometimes', 'string', 'timezone'],
],
NodeType::FetchRss->value => [
'feed_url' => ['required', 'url'],
],
NodeType::HttpRequest->value => [
'url' => ['required', 'url'],
'method' => ['required', Rule::in(array_column(HttpMethod::cases(), 'value'))],
'auth_type' => ['required', Rule::in(array_column(AuthType::cases(), 'value'))],
'auth_token' => ['nullable', 'string'],
'auth_username' => ['nullable', 'string'],
'auth_password' => ['nullable', 'string'],
'auth_header_name' => ['nullable', 'string'],
'body_template' => ['nullable', 'string'],
'headers' => ['nullable', 'array'],
'headers.*' => ['string'],
'items_path' => ['nullable', 'string'],
'item_key_path' => ['nullable', 'string'],
'item_date_path' => ['nullable', 'string'],
],
NodeType::Generate->value => [
'accounts' => ['required', 'array', 'min:1'],
'prompt_template' => ['required', 'string'],
'target_slide_count' => ['nullable', 'integer', 'min:0', 'max:'.GenerateNodeValidator::MAX_GENERATED_IMAGES],
'use_brand_voice' => ['sometimes', 'boolean'],
'use_brand_visuals' => ['sometimes', 'boolean'],
],
NodeType::Delay->value => [
'duration' => ['required', 'integer', 'min:1'],
'unit' => ['required', Rule::in(array_column(DelayUnit::cases(), 'value'))],
],
NodeType::Condition->value => [
'field' => ['required', 'string'],
'operator' => ['required', Rule::in(array_column(ConditionOperator::cases(), 'value'))],
'value' => ['nullable', 'string'],
],
NodeType::Publish->value => [
'mode' => ['required', Rule::in(array_column(PublishMode::cases(), 'value'))],
'scheduled_offset' => ['required_if:nodes.'.$i.'.data.mode,'.PublishMode::Scheduled->value, 'integer', 'min:0'],
],
NodeType::Webhook->value => [
'url' => ['required', 'url'],
'method' => ['required', Rule::in(array_column(HttpMethod::cases(), 'value'))],
'payload_template' => ['nullable', 'string'],
'headers' => ['nullable', 'array'],
'headers.*' => ['string'],
],
NodeType::End->value => [
'reason' => ['nullable', 'string'],
],
default => [],
};
}
}

View file

@ -5,6 +5,7 @@
namespace App\Http\Requests\App\Workspace;
use App\Enums\Workspace\BrandFont;
use App\Enums\Workspace\BrandVoiceTrait;
use App\Enums\Workspace\ImageStyle;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@ -24,8 +25,8 @@ public function rules(): array
'name' => ['required', 'string', 'max:255'],
'brand_website' => ['nullable', 'url', 'max:255'],
'brand_description' => ['nullable', 'string', 'max:2000'],
'brand_tone' => ['nullable', 'string', 'in:professional,casual,friendly,bold,inspirational,humorous,educational'],
'brand_voice_notes' => ['nullable', 'string', 'max:2000'],
'brand_voice_traits' => ['nullable', 'array'],
'brand_voice_traits.*' => ['string', Rule::enum(BrandVoiceTrait::class)],
'brand_color' => $hex,
'background_color' => $hex,
'text_color' => $hex,

View file

@ -5,6 +5,7 @@
namespace App\Http\Requests\App\Workspace;
use App\Enums\Workspace\BrandFont;
use App\Enums\Workspace\BrandVoiceTrait;
use App\Enums\Workspace\ImageStyle;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@ -24,8 +25,8 @@ public function rules(): array
'name' => ['required', 'string', 'max:255'],
'brand_website' => ['nullable', 'url', 'max:255'],
'brand_description' => ['nullable', 'string', 'max:2000'],
'brand_tone' => ['nullable', 'string', 'in:professional,casual,friendly,bold,inspirational,humorous,educational'],
'brand_voice_notes' => ['nullable', 'string', 'max:2000'],
'brand_voice_traits' => ['nullable', 'array'],
'brand_voice_traits.*' => ['string', Rule::enum(BrandVoiceTrait::class)],
'brand_color' => $hex,
'background_color' => $hex,
'text_color' => $hex,

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* A single execution row for the Invocations tab: enough to render the list
* (status, timing, step count, error summary) without loading every node run.
*/
class AutomationInvocationResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'status' => $this->status->value,
'is_manual' => (bool) $this->is_manual,
'node_run_count' => (int) ($this->node_runs_count ?? 0),
'duration_ms' => $this->durationInMilliseconds(),
'error_message' => is_array($this->error) ? ($this->error['message'] ?? null) : $this->error,
'created_at' => $this->created_at,
'started_at' => $this->started_at,
'finished_at' => $this->finished_at,
];
}
}

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class AutomationNodeRunResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'node_id' => $this->node_id,
'node_type' => $this->node_type->value,
'status' => $this->status->value,
'input' => $this->input,
'output' => $this->output,
'error' => $this->error,
'started_at' => $this->started_at,
'finished_at' => $this->finished_at,
];
}
}

View file

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Automation;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class AutomationResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'workspace_id' => $this->workspace_id,
'name' => $this->name,
'status' => $this->status->value,
'nodes' => $this->maskSensitiveNodeFields($this->nodes ?? []),
'connections' => $this->connections ?? [],
'variables' => $this->maskVariables($this->variables ?? []),
'activated_at' => $this->activated_at,
'paused_at' => $this->paused_at,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
/**
* Replace any stored credentials with a placeholder before they leave
* the server. The frontend treats the placeholder as "keep current" on
* save (see Automation::booted()), so editing other fields doesn't wipe
* the stored secret.
*
* @param array<int, array<string, mixed>> $nodes
* @return array<int, array<string, mixed>>
*/
private function maskSensitiveNodeFields(array $nodes): array
{
foreach ($nodes as &$node) {
foreach (Automation::SENSITIVE_NODE_FIELDS as $field) {
if (data_get($node, "data.{$field}") !== null && data_get($node, "data.{$field}") !== '') {
data_set($node, "data.{$field}", Automation::SENSITIVE_PLACEHOLDER);
}
}
}
return $nodes;
}
/**
* Replace stored variable values with the placeholder so secrets never
* leave the server. The frontend references variables by key
* (`{{ variables.KEY }}`), so the masked value doesn't hinder reuse, and
* re-saving the placeholder keeps the stored ciphertext.
*
* @param array<int, array<string, mixed>> $variables
* @return array<int, array<string, mixed>>
*/
private function maskVariables(array $variables): array
{
foreach ($variables as &$variable) {
if (data_get($variable, 'value') !== null && data_get($variable, 'value') !== '') {
$variable['value'] = Automation::SENSITIVE_PLACEHOLDER;
}
}
return $variables;
}
}

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class AutomationRunResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'automation_id' => $this->automation_id,
'trigger_item_id' => $this->trigger_item_id,
'current_node_id' => $this->current_node_id,
'status' => $this->status->value,
'is_manual' => (bool) $this->is_manual,
'is_dry_run' => (bool) $this->is_dry_run,
'next_action_at' => $this->next_action_at,
'generated_post_id' => $this->generated_post_id,
'context' => $this->context,
'error' => $this->error,
'started_at' => $this->started_at,
'finished_at' => $this->finished_at,
];
}
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class AutomationTriggerItemResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'item_key' => $this->item_key,
'payload' => $this->payload,
'first_seen_at' => $this->first_seen_at,
'run' => AutomationRunResource::make($this->whenLoaded('run')),
];
}
}

View file

@ -7,8 +7,6 @@
use App\Actions\Post\CreatePost;
use App\Ai\Agents\PostContentGenerator;
use App\Ai\Agents\PostContentHumanizer;
use App\Enums\Media\Source;
use App\Enums\Media\Type as MediaType;
use App\Enums\Notification\Channel as NotificationChannel;
use App\Enums\Notification\Type as NotificationType;
use App\Enums\PostPlatform\ContentType;
@ -18,17 +16,14 @@
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Ai\AiImageClient;
use App\Services\Ai\RecordAiUsage;
use App\Services\Image\BrandColorMapper;
use App\Services\Image\TemplateImageGenerator;
use App\Services\Image\PostImagePipeline;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
class StreamPostCreation implements ShouldQueue
{
@ -101,32 +96,6 @@ public function handle(): void
}
}
/**
* Run the structured generator output through the humanizer pass and merge
* the humanized text fields back over the original structure (preserving
* image_keywords and slide order/count). Failures are logged and the
* original structure is returned so generation never breaks because of the
* polish step.
*
* @param array<string, mixed> $structured
* @return array<string, mixed>
*/
/**
* Look up the AI image dimensions for the current format. Falls back to
* the generator's defaults (4:5 portrait) if the format string isn't a
* known ContentType case.
*
* @return array{width: int, height: int}
*/
private function dimensionsForFormat(): array
{
$type = $this->resolvedContentType();
return $type
? $type->aiImageDimensions()
: ['width' => TemplateImageGenerator::DEFAULT_WIDTH, 'height' => TemplateImageGenerator::DEFAULT_HEIGHT];
}
/**
* The stored content type for the requested generation format. The carousel
* generation format is persisted as an Instagram feed post.
@ -140,6 +109,16 @@ private function resolvedContentType(): ?ContentType
return ContentType::tryFrom($this->format);
}
/**
* Run the structured generator output through the humanizer pass and merge
* the humanized text fields back over the original structure (preserving
* image_keywords and slide order/count). Failures are logged and the
* original structure is returned so generation never breaks because of the
* polish step.
*
* @param array<string, mixed> $structured
* @return array<string, mixed>
*/
private function humanize(Workspace $workspace, array $structured, string $format): array
{
try {
@ -160,7 +139,7 @@ private function humanize(Workspace $workspace, array $structured, string $forma
'image_body' => data_get($structured, 'image_body', ''),
];
$humanizer = new PostContentHumanizer($workspace, $format);
$humanizer = new PostContentHumanizer($workspace, $format, platformContext: $this->format);
$response = $humanizer->prompt(json_encode($input, JSON_UNESCAPED_UNICODE));
$humanized = $response->structured ?? [];
@ -207,29 +186,16 @@ private function humanize(Workspace $workspace, array $structured, string $forma
private function handleCarousel(Workspace $workspace, ?SocialAccount $socialAccount, array $structured): void
{
$caption = (string) data_get($structured, 'caption', '');
$slides = data_get($structured, 'slides', []);
$media = [];
if ($socialAccount) {
$generator = new TemplateImageGenerator(new BrandColorMapper, new AiImageClient);
['width' => $width, 'height' => $height] = $this->dimensionsForFormat();
foreach ($slides as $slide) {
$rendered = $generator->render(
workspace: $workspace,
socialAccount: $socialAccount,
title: data_get($slide, 'title', ''),
body: data_get($slide, 'body', ''),
imageKeywords: data_get($slide, 'image_keywords', []),
width: $width,
height: $height,
);
if ($rendered) {
$media[] = $this->buildAiMediaItem($workspace, $rendered);
}
}
$media = app(PostImagePipeline::class)->forCarousel(
workspace: $workspace,
account: $socialAccount,
structured: $structured,
contentType: $this->resolvedContentType(),
);
}
$post = $this->createPost($workspace, $caption, $media, $socialAccount);
@ -246,29 +212,16 @@ private function handleSingle(Workspace $workspace, ?SocialAccount $socialAccoun
$supportsCaption = $contentType?->supportsCaption() ?? true;
$rawContent = (string) data_get($structured, 'content', data_get($structured, 'text', ''));
$imageTitle = (string) data_get($structured, 'image_title', '');
$imageBody = (string) data_get($structured, 'image_body', '');
$keywords = data_get($structured, 'image_keywords', []);
$media = [];
if ($this->imageCount > 0 && $socialAccount) {
$generator = new TemplateImageGenerator(new BrandColorMapper, new AiImageClient);
['width' => $width, 'height' => $height] = $this->dimensionsForFormat();
$rendered = $generator->render(
$media = app(PostImagePipeline::class)->forSingle(
workspace: $workspace,
socialAccount: $socialAccount,
title: $imageTitle,
body: $imageBody,
imageKeywords: $keywords,
width: $width,
height: $height,
account: $socialAccount,
structured: $structured,
contentType: $this->resolvedContentType(),
);
if ($rendered) {
$media[] = $this->buildAiMediaItem($workspace, $rendered);
}
}
$caption = $supportsCaption ? $rawContent : '';
@ -345,31 +298,4 @@ private function aspectRatioFor(ContentType $type): ?string
default => null,
};
}
/**
* @param array{path: string, source_meta: array<string, mixed>} $rendered
* @return array<string, mixed>
*/
private function buildAiMediaItem(Workspace $workspace, array $rendered): array
{
$media = $workspace->media()->create([
'collection' => 'ai-generated',
'type' => MediaType::Image,
'path' => $rendered['path'],
'original_filename' => basename($rendered['path']),
'mime_type' => 'image/webp',
'size' => Storage::size($rendered['path']),
'order' => 0,
]);
return [
'id' => $media->id,
'path' => $media->path,
'url' => $media->url,
'type' => 'image',
'mime_type' => 'image/webp',
'source' => Source::Ai->value,
'source_meta' => $rendered['source_meta'],
];
}
}

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Automation;
use App\Actions\Automation\Trigger\DispatchPostTriggerAutomations;
use App\Enums\Automation\Trigger\Type as TriggerType;
use App\Models\Post;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class DispatchPostTriggerAutomationsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1;
public function __construct(
public Post $post,
public TriggerType $triggerType,
) {
$this->onQueue('automations');
}
public function handle(DispatchPostTriggerAutomations $dispatch): void
{
$dispatch($this->post, $this->triggerType);
}
}

View file

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

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

@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Enums\Automation\Node\Type as NodeType;
use App\Enums\Automation\Status;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Crypt;
use Throwable;
class Automation extends Model
{
use HasFactory;
use HasUuids;
/**
* Fields inside `nodes[].data` that hold sensitive credentials. Stored
* encrypted on save and never returned to the frontend in plain text —
* AutomationResource masks them with PLACEHOLDER on output.
*/
public const SENSITIVE_NODE_FIELDS = ['auth_token', 'auth_password'];
public const SENSITIVE_PLACEHOLDER = '••••••••';
protected $guarded = [];
protected $casts = [
'status' => Status::class,
'nodes' => 'array',
'connections' => 'array',
'variables' => 'array',
'activated_at' => 'datetime',
'paused_at' => 'datetime',
];
protected static function booted(): void
{
static::saving(function (self $automation): void {
$automation->nodes = self::encryptSensitiveFields(
$automation->nodes ?? [],
$automation->getOriginal('nodes') ?? [],
);
$automation->variables = self::encryptVariables(
$automation->variables ?? [],
$automation->getOriginal('variables') ?? [],
);
$automation->trigger_type = self::deriveTriggerType($automation->nodes ?? []);
});
}
/**
* 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);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function triggerItems(): HasMany
{
return $this->hasMany(AutomationTriggerItem::class);
}
public function runs(): HasMany
{
return $this->hasMany(AutomationRun::class);
}
/**
* Walks both the incoming and stored node lists and reconciles sensitive
* fields: a PLACEHOLDER value means "user didn't change it" (frontend
* never received the real value) so we keep the existing ciphertext. Plain
* text values get encrypted; already-encrypted strings pass through.
*
* Denormalize the trigger node's type into an indexed column so the
* scheduler can filter by it in SQL instead of decoding every automation's
* `nodes` JSON each minute. Recomputed on every save so it cannot drift.
*
* @param array<int, array<string, mixed>> $nodes
*/
private static function deriveTriggerType(array $nodes): ?string
{
$triggerNode = collect($nodes)->firstWhere('type', NodeType::Trigger->value);
return data_get($triggerNode, 'data.trigger_type');
}
/**
* @param array<int, array<string, mixed>> $incoming
* @param array<int, array<string, mixed>>|string $original
* @return array<int, array<string, mixed>>
*/
private static function encryptSensitiveFields(array $incoming, array|string $original): array
{
$original = is_array($original) ? $original : (json_decode($original, true) ?: []);
$originalById = collect($original)->keyBy('id');
foreach ($incoming as &$node) {
$originalNode = $originalById->get($node['id'] ?? null);
foreach (self::SENSITIVE_NODE_FIELDS as $field) {
$value = data_get($node, "data.{$field}");
if (! is_string($value) || $value === '') {
continue;
}
if ($value === self::SENSITIVE_PLACEHOLDER) {
data_set($node, "data.{$field}", data_get($originalNode, "data.{$field}", ''));
continue;
}
if (self::looksEncrypted($value)) {
continue;
}
data_set($node, "data.{$field}", Crypt::encryptString($value));
}
}
return $incoming;
}
/**
* 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
* JSON beginning with the canonical `eyJpdiI` ("{"iv":"...) prefix.
*/
private static function looksEncrypted(string $value): bool
{
if (! str_starts_with($value, 'eyJ')) {
return false;
}
try {
Crypt::decryptString($value);
return true;
} catch (Throwable) {
return false;
}
}
}

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Enums\Automation\Node\Type as NodeType;
use App\Enums\Automation\NodeRun\Status;
use App\Observers\AutomationNodeRunObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[ObservedBy([AutomationNodeRunObserver::class])]
class AutomationNodeRun extends Model
{
use HasFactory;
use HasUuids;
protected $guarded = [];
protected $casts = [
'status' => Status::class,
'node_type' => NodeType::class,
'input' => 'array',
'output' => 'array',
'error' => 'array',
'started_at' => 'datetime',
'finished_at' => 'datetime',
];
public function run(): BelongsTo
{
return $this->belongsTo(AutomationRun::class, 'run_id');
}
}

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AutomationNodeState extends Model
{
use HasFactory;
use HasUuids;
protected $guarded = [];
protected $casts = [
'data' => 'array',
];
public function automation(): BelongsTo
{
return $this->belongsTo(Automation::class);
}
/**
* Idempotent lookup for the state row of a given node within an automation,
* creating an empty row on first access. Use this in poll/fire actions that
* need to read or update an internal watermark.
*/
public static function for(string $automationId, string $nodeId): self
{
return self::firstOrCreate(
['automation_id' => $automationId, 'node_id' => $nodeId],
['data' => []],
);
}
}

View file

@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Enums\Automation\Run\Status;
use App\Observers\AutomationRunObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[ObservedBy([AutomationRunObserver::class])]
class AutomationRun extends Model
{
use HasFactory;
use HasUuids;
protected $guarded = [];
protected $casts = [
'status' => Status::class,
'context' => 'array',
'error' => 'array',
'is_manual' => 'boolean',
'is_dry_run' => 'boolean',
'next_action_at' => 'datetime',
'started_at' => 'datetime',
'finished_at' => 'datetime',
];
public function automation(): BelongsTo
{
return $this->belongsTo(Automation::class);
}
/**
* Id of the run that started this execution. Fan-out forks sibling runs that
* all point back at the same root, so callers can treat every branch of one
* test/trigger as a single family. The root run points at itself.
*/
public function rootId(): string
{
return $this->root_run_id ?? $this->id;
}
/**
* Wall-clock execution time, or null while the run hasn't both started and
* finished. Single source of truth for the Invocations list and metrics.
*/
public function durationInMilliseconds(): ?int
{
if ($this->started_at === null || $this->finished_at === null) {
return null;
}
return (int) $this->started_at->diffInMilliseconds($this->finished_at);
}
/**
* 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');
}
public function generatedPost(): BelongsTo
{
return $this->belongsTo(Post::class, 'generated_post_id');
}
public function nodeRuns(): HasMany
{
return $this->hasMany(AutomationNodeRun::class, 'run_id');
}
/**
* Real, production executions only — excludes manual test runs (both dry
* runs and "with real data" tests are flagged is_manual). The Invocations
* and Metrics tabs report on these, not on runs the user triggered to test
* the editor.
*/
public function scopeProductionRuns(Builder $query): Builder
{
return $query->where('is_manual', false)->where('is_dry_run', false);
}
}

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
class AutomationTriggerItem extends Model
{
use HasFactory;
use HasUuids;
protected $guarded = [];
protected $casts = [
'payload' => 'array',
'first_seen_at' => 'datetime',
];
public function automation(): BelongsTo
{
return $this->belongsTo(Automation::class);
}
public function run(): HasOne
{
return $this->hasOne(AutomationRun::class, 'trigger_item_id');
}
}

View file

@ -7,7 +7,9 @@
use App\DataTransferObjects\MediaItem;
use App\Enums\Media\Type;
use App\Enums\Post\Status as PostStatus;
use App\Observers\PostObserver;
use Database\Factories\PostFactory;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
@ -19,6 +21,7 @@
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
#[ObservedBy([PostObserver::class])]
class Post extends Model
{
/** @use HasFactory<PostFactory> */

View file

@ -26,8 +26,7 @@ class Workspace extends Model
'name',
'brand_website',
'brand_description',
'brand_tone',
'brand_voice_notes',
'brand_voice_traits',
'brand_color',
'background_color',
'text_color',
@ -40,6 +39,7 @@ protected function casts(): array
{
return [
'image_style' => ImageStyle::class,
'brand_voice_traits' => 'array',
];
}

View file

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

View file

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

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Observers;
use App\Enums\Automation\Trigger\Type as TriggerType;
use App\Enums\Post\Status as PostStatus;
use App\Jobs\Automation\DispatchPostTriggerAutomationsJob;
use App\Models\Post;
class PostObserver
{
public function saved(Post $post): void
{
if (! $post->wasChanged('status')) {
return;
}
$triggerType = match ($post->status) {
PostStatus::Published => TriggerType::PostPublished,
PostStatus::Scheduled => TriggerType::PostScheduled,
default => null,
};
if ($triggerType === null) {
return;
}
DispatchPostTriggerAutomationsJob::dispatch($post, $triggerType)->afterCommit();
}
}

View file

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\Automation;
use App\Models\User;
class AutomationPolicy
{
public function viewAny(User $user): bool
{
return $user->currentWorkspace !== null;
}
public function view(User $user, Automation $automation): bool
{
return $automation->workspace_id === $user->current_workspace_id;
}
public function create(User $user): bool
{
return $user->currentWorkspace !== null;
}
public function update(User $user, Automation $automation): bool
{
return $automation->workspace_id === $user->current_workspace_id;
}
public function delete(User $user, Automation $automation): bool
{
return $automation->workspace_id === $user->current_workspace_id;
}
public function activate(User $user, Automation $automation): bool
{
return $this->update($user, $automation);
}
public function pause(User $user, Automation $automation): bool
{
return $this->update($user, $automation);
}
}

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Services\Automation;
use App\Enums\Automation\Node\Type as NodeType;
/**
* Single source of truth for per-node config validation. Walks an automation's
* nodes and reports every config issue, delegating to the type-specific
* validators. Shared by save (field-keyed errors), activation, and the editor
* test run so a misconfigured node is rejected up front with a clear message
* instead of failing midway through execution.
*/
final class AutomationConfigValidator
{
public function __construct(
private GenerateNodeValidator $generateValidator,
private WebhookNodeValidator $webhookValidator,
) {}
/**
* Every config issue across the given nodes, in node order.
*
* @param array<int, array<string, mixed>> $nodes
* @return list<array{node_index: int, field: string, message: string}>
*/
public function issues(array $nodes): array
{
$issues = [];
foreach ($nodes as $index => $node) {
$config = (array) data_get($node, 'data', []);
[$field, $message] = match (data_get($node, 'type')) {
NodeType::Generate->value => ['accounts', $this->generateValidator->issueFor($config)],
NodeType::Webhook->value => ['payload_template', $this->webhookValidator->issueFor($config)],
default => [null, null],
};
if ($message !== null) {
$issues[] = ['node_index' => $index, 'field' => $field, 'message' => $message];
}
}
return $issues;
}
/**
* The first config issue's message, or null when every node is runnable.
*
* @param array<int, array<string, mixed>> $nodes
*/
public function firstMessage(array $nodes): ?string
{
return $this->issues($nodes)[0]['message'] ?? null;
}
}

View file

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Services\Automation;
use Illuminate\Support\Carbon;
class ExpressionResolver
{
public function resolve(string $template, array $context): string
{
return preg_replace_callback(
'/\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}/',
fn ($matches) => $this->resolveVariable($matches[1], $context),
$template,
) ?? $template;
}
/**
* Resolves `{{ ... }}` placeholders inside an already-decoded JSON structure
* (arrays/strings), resolving only string leaves. The caller json_encodes the
* result, so values are never string-interpolated into raw JSON — quotes,
* `&`, newlines etc. in the data can't corrupt the payload.
*
* @param array<string, mixed> $context
*/
public function resolveStructured(mixed $value, array $context): mixed
{
if (is_string($value)) {
return $this->resolve($value, $context);
}
if (is_array($value)) {
return array_map(fn ($item) => $this->resolveStructured($item, $context), $value);
}
return $value;
}
private function resolveVariable(string $path, array $context): string
{
if ($path === 'now') {
return Carbon::now()->toIso8601String();
}
if ($path === 'today') {
return Carbon::today()->toDateString();
}
$value = data_get($context, $path);
if ($value === null) {
return '';
}
if (is_scalar($value)) {
return (string) $value;
}
// json_encode returns false on malformed UTF-8 (plausible for scraped
// feed/HTTP payloads); the method must still return a string.
return json_encode($value, JSON_PARTIAL_OUTPUT_ON_ERROR) ?: '';
}
}

View file

@ -0,0 +1,73 @@
<?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;
}
// Single source of truth: 0 = text-only, 1 = single image, 2+ = carousel.
$imageCount = (int) data_get($config, 'target_slide_count', 1);
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;
}
private function issueForAccount(ContentType $contentType, int $imageCount): ?string
{
if ($contentType->requiresMedia() && $imageCount === 0) {
return __('posts.edit.compliance.requires_media');
}
if ($imageCount > 0 && ! $contentType->supportsImage()) {
return __('posts.edit.compliance.no_images');
}
$max = min(self::MAX_GENERATED_IMAGES, $contentType->maxMediaCount());
if ($imageCount > $max) {
return __('posts.edit.compliance.too_many_files', ['max' => (string) $max]);
}
return null;
}
}

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Services\Automation;
/**
* Backend mirror of the Webhook node's payload-template contract: the template
* is parsed as JSON before placeholders are resolved (see RunWebhookNode), so a
* template with unquoted `{{ }}` placeholders or any malformed JSON can never
* run. An empty or literal-`null` template means "no body" and is valid.
*/
final class WebhookNodeValidator
{
/**
* First compliance issue for a webhook node's config, or null when valid.
*
* @param array<string, mixed> $config
*/
public function issueFor(array $config): ?string
{
$template = trim((string) data_get($config, 'payload_template', ''));
if ($template === '' || $template === 'null') {
return null;
}
json_decode($template);
if (json_last_error() !== JSON_ERROR_NONE) {
return __('automations.errors.webhook_invalid_payload_json');
}
return null;
}
}

View file

@ -6,16 +6,18 @@
final readonly class BrandMetadata
{
/**
* @param array<int, string> $voiceTraits
*/
public function __construct(
public ?string $name = null,
public ?string $description = null,
public ?string $language = null,
public ?string $tone = null,
public ?string $voiceNotes = null,
public ?string $logoUrl = null,
public ?string $brandColor = null,
public ?string $backgroundColor = null,
public ?string $textColor = null,
public array $voiceTraits = [],
) {}
public function mergeLlm(LlmBrandAnalysis $llm): self
@ -24,8 +26,6 @@ public function mergeLlm(LlmBrandAnalysis $llm): self
name: $llm->name ?: $this->name,
description: $llm->description ?: $this->description,
language: $llm->language ?: $this->language,
tone: $llm->tone ?: null,
voiceNotes: $llm->voiceNotes ?: null,
logoUrl: $this->logoUrl,
// Prefer deterministically-extracted colors (theme-color meta, CSS
// custom properties, body rules) — the LLM only sees stripped
@ -33,6 +33,7 @@ public function mergeLlm(LlmBrandAnalysis $llm): self
brandColor: $this->brandColor ?: ($llm->brandColor ?: null),
backgroundColor: $this->backgroundColor ?: ($llm->backgroundColor ?: null),
textColor: $this->textColor ?: ($llm->textColor ?: null),
voiceTraits: $llm->voiceTraits ?: $this->voiceTraits,
);
}
@ -42,12 +43,11 @@ public function withLogoUrl(?string $logoUrl): self
name: $this->name,
description: $this->description,
language: $this->language,
tone: $this->tone,
voiceNotes: $this->voiceNotes,
logoUrl: $logoUrl,
brandColor: $this->brandColor,
backgroundColor: $this->backgroundColor,
textColor: $this->textColor,
voiceTraits: $this->voiceTraits,
);
}
@ -57,12 +57,11 @@ public function withBrandColor(?string $brandColor): self
name: $this->name,
description: $this->description,
language: $this->language,
tone: $this->tone,
voiceNotes: $this->voiceNotes,
logoUrl: $this->logoUrl,
brandColor: $brandColor,
backgroundColor: $this->backgroundColor,
textColor: $this->textColor,
voiceTraits: $this->voiceTraits,
);
}
@ -73,7 +72,7 @@ public function withBrandColor(?string $brandColor): self
* For AI image generation we swap them: page text (usually dark) becomes
* image background, and page background (usually light) becomes in-image text.
*
* @return array{name: ?string, brand_description: ?string, content_language: ?string, brand_tone: ?string, brand_voice_notes: ?string, brand_color: ?string, background_color: ?string, text_color: ?string, logo_url: ?string}
* @return array{name: ?string, brand_description: ?string, content_language: ?string, brand_color: ?string, background_color: ?string, text_color: ?string, logo_url: ?string, brand_voice_traits: array<int, string>}
*/
public function toArray(): array
{
@ -81,12 +80,11 @@ public function toArray(): array
'name' => $this->name,
'brand_description' => $this->description,
'content_language' => $this->language,
'brand_tone' => $this->tone,
'brand_voice_notes' => $this->voiceNotes,
'brand_color' => $this->brandColor,
'background_color' => $this->textColor,
'text_color' => $this->backgroundColor,
'logo_url' => $this->logoUrl,
'brand_voice_traits' => $this->voiceTraits,
];
}
}

View file

@ -4,32 +4,36 @@
namespace App\Services\Brand;
use App\Enums\Workspace\BrandVoiceTrait;
use ArrayAccess;
final readonly class LlmBrandAnalysis
{
/**
* @param array<int, string> $voiceTraits
*/
public function __construct(
public string $name = '',
public string $description = '',
public string $tone = '',
public string $language = '',
public string $voiceNotes = '',
public string $brandColor = '',
public string $backgroundColor = '',
public string $textColor = '',
public array $voiceTraits = [],
) {}
public static function fromResponse(ArrayAccess|array $response): self
{
$traits = data_get($response, 'voice_traits', []);
return new self(
name: trim((string) data_get($response, 'name', '')),
description: trim((string) data_get($response, 'description', '')),
tone: trim((string) data_get($response, 'tone', '')),
language: trim((string) data_get($response, 'language', '')),
voiceNotes: trim((string) data_get($response, 'voice_notes', '')),
brandColor: self::normalizeHex((string) data_get($response, 'brand_color', '')),
backgroundColor: self::normalizeHex((string) data_get($response, 'background_color', '')),
textColor: self::normalizeHex((string) data_get($response, 'text_color', '')),
voiceTraits: BrandVoiceTrait::coerce(is_array($traits) ? $traits : []),
);
}

View file

@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace App\Services\Image;
use App\Enums\Media\Source;
use App\Enums\Media\Type as MediaType;
use App\Enums\PostPlatform\ContentType;
use App\Models\SocialAccount;
use App\Models\Workspace;
use Illuminate\Support\Facades\Storage;
class PostImagePipeline
{
public function __construct(
private TemplateImageGenerator $generator,
) {}
/**
* Render the single AI image for a structured generator output. Returns a
* one-element media-item array when an image is produced, or an empty array
* when the generator renders nothing.
*
* @param array<string, mixed> $structured
* @return array<int, array<string, mixed>>
*/
public function forSingle(Workspace $workspace, SocialAccount $account, array $structured, ?ContentType $contentType, bool $applyBrandVisuals = true): array
{
['width' => $width, 'height' => $height] = $this->dimensionsForContentType($contentType);
$rendered = $this->generator->render(
workspace: $workspace,
socialAccount: $account,
title: (string) data_get($structured, 'image_title', ''),
body: (string) data_get($structured, 'image_body', ''),
imageKeywords: data_get($structured, 'image_keywords', []),
width: $width,
height: $height,
applyBrandVisuals: $applyBrandVisuals,
);
if (! $rendered) {
return [];
}
return [$this->buildAiMediaItem($workspace, $rendered)];
}
/**
* Render one AI image per slide in the structured carousel output. Slides
* that render nothing are skipped.
*
* @param array<string, mixed> $structured
* @return array<int, array<string, mixed>>
*/
public function forCarousel(Workspace $workspace, SocialAccount $account, array $structured, ?ContentType $contentType, bool $applyBrandVisuals = true): array
{
['width' => $width, 'height' => $height] = $this->dimensionsForContentType($contentType);
$media = [];
foreach (data_get($structured, 'slides', []) as $slide) {
$rendered = $this->generator->render(
workspace: $workspace,
socialAccount: $account,
title: (string) data_get($slide, 'title', ''),
body: (string) data_get($slide, 'body', ''),
imageKeywords: data_get($slide, 'image_keywords', []),
width: $width,
height: $height,
applyBrandVisuals: $applyBrandVisuals,
);
if ($rendered) {
$media[] = $this->buildAiMediaItem($workspace, $rendered);
}
}
return $media;
}
/**
* Resolve the AI image dimensions for the given content type, falling back
* to the generator defaults (4:5 portrait) when no content type is known.
*
* @return array{width: int, height: int}
*/
private function dimensionsForContentType(?ContentType $contentType): array
{
return $contentType
? $contentType->aiImageDimensions()
: ['width' => TemplateImageGenerator::DEFAULT_WIDTH, 'height' => TemplateImageGenerator::DEFAULT_HEIGHT];
}
/**
* @param array{path: string, source_meta: array<string, mixed>} $rendered
* @return array<string, mixed>
*/
private function buildAiMediaItem(Workspace $workspace, array $rendered): array
{
$media = $workspace->media()->create([
'collection' => 'ai-generated',
'type' => MediaType::Image,
'path' => $rendered['path'],
'original_filename' => basename($rendered['path']),
'mime_type' => 'image/webp',
'size' => Storage::size($rendered['path']),
'order' => 0,
]);
return [
'id' => $media->id,
'path' => $media->path,
'url' => $media->url,
'type' => 'image',
'mime_type' => 'image/webp',
'source' => Source::Ai->value,
'source_meta' => $rendered['source_meta'],
];
}
}

View file

@ -51,6 +51,7 @@ public function render(
int $width = self::DEFAULT_WIDTH,
int $height = self::DEFAULT_HEIGHT,
?string $backgroundPath = null,
bool $applyBrandVisuals = true,
): ?array {
$this->width = $width;
$this->height = $height;
@ -64,6 +65,14 @@ public function render(
};
$language = $workspace->content_language;
// When brand visuals are off (e.g. faithful curation), the AI background
// is generated without brand colours or identity — neutral imagery driven
// only by the post's own keywords.
$brandColor = $applyBrandVisuals ? $workspace->brand_color : null;
$backgroundColor = $applyBrandVisuals ? $workspace->background_color : null;
$textColor = $applyBrandVisuals ? $workspace->text_color : null;
$brandDescription = $applyBrandVisuals ? $workspace->brand_description : null;
$generatedNewBackground = false;
$resolvedBackgroundPath = null;
$imageData = null;
@ -79,10 +88,10 @@ public function render(
style: $imageStyle,
orientation: $orientation,
language: $language,
brandColor: $workspace->brand_color,
backgroundColor: $workspace->background_color,
textColor: $workspace->text_color,
brandDescription: $workspace->brand_description,
brandColor: $brandColor,
backgroundColor: $backgroundColor,
textColor: $textColor,
brandDescription: $brandDescription,
);
if ($imageData === null) {
@ -134,9 +143,9 @@ public function render(
'body' => $body,
'width' => $this->width,
'height' => $this->height,
'brand_color' => $workspace->brand_color,
'background_color' => $workspace->background_color,
'text_color' => $workspace->text_color,
'brand_color' => $brandColor,
'background_color' => $backgroundColor,
'text_color' => $textColor,
'background_path' => $resolvedBackgroundPath,
],
];

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
use App\Http\Middleware\Api\LoadWorkspaceFromToken;
use App\Http\Middleware\App\EnsureRegistrationEnabled;
use App\Http\Middleware\App\HandleInertiaRequests;
@ -54,4 +56,10 @@
], 429)->withHeaders($e->getHeaders());
}
});
$exceptions->render(function (DomainException $e, Request $request) {
if ($request->expectsJson()) {
return response()->json(['message' => $e->getMessage()], 422);
}
});
})->create();

View file

@ -255,6 +255,21 @@
'tries' => 1,
'nice' => 0,
],
'automations' => [
'connection' => 'redis',
'queue' => ['automations'],
'balance' => 'auto',
'autoScalingStrategy' => 'time',
'minProcesses' => 1,
'maxProcesses' => 3,
'timeout' => 630,
'maxTime' => 0,
'maxJobs' => 0,
'memory' => 256,
'tries' => 1,
'nice' => 0,
],
],
'environments' => [
@ -276,6 +291,12 @@
'balanceMaxShift' => 1,
'balanceCooldown' => 3,
],
'automations' => [
'maxProcesses' => 5,
'balanceMaxShift' => 1,
'balanceCooldown' => 3,
],
],
'local' => [
@ -290,6 +311,10 @@
'ai-assistant' => [
'maxProcesses' => 2,
],
'automations' => [
'maxProcesses' => 2,
],
],
],
];

View file

@ -59,6 +59,19 @@
|
*/
/*
|--------------------------------------------------------------------------
| Outbound User-Agent
|--------------------------------------------------------------------------
|
| Branded User-Agent applied to outbound HTTP from automation nodes
| (webhook + http_request) so recipients know the request came from
| TryPost.it. Self-hosters can override it.
|
*/
'user_agent' => env('TRYPOST_USER_AGENT', 'TryPost.it/1.0 (+https://trypost.it)'),
'google_auth_enabled' => env('GOOGLE_AUTH_ENABLED', false),
'github_auth_enabled' => env('GITHUB_AUTH_ENABLED', false),

View file

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Enums\Automation\Status;
use App\Models\Automation;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Database\Eloquent\Factories\Factory;
class AutomationFactory extends Factory
{
protected $model = Automation::class;
public function definition(): array
{
return [
'workspace_id' => Workspace::factory(),
'user_id' => User::factory(),
'name' => fake()->sentence(3),
'status' => Status::Draft,
'nodes' => [],
'connections' => [],
];
}
public function active(): static
{
return $this->state(fn () => [
'status' => Status::Active,
'activated_at' => now(),
]);
}
public function paused(): static
{
return $this->state(fn () => [
'status' => Status::Paused,
'paused_at' => now(),
]);
}
public function withScheduleTrigger(string $cron = '0 9 * * *'): static
{
return $this->state(fn () => [
'nodes' => [
[
'id' => 'trigger_1',
'type' => 'trigger',
'position' => ['x' => 0, 'y' => 0],
'data' => [
'trigger_type' => 'schedule',
'cron' => $cron,
],
],
],
'connections' => [],
]);
}
}

View file

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

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Automation;
use App\Models\AutomationNodeState;
use Illuminate\Database\Eloquent\Factories\Factory;
class AutomationNodeStateFactory extends Factory
{
protected $model = AutomationNodeState::class;
public function definition(): array
{
return [
'automation_id' => Automation::factory(),
'node_id' => 'node_'.fake()->randomNumber(6),
'data' => [],
];
}
}

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Enums\Automation\Run\Status;
use App\Models\Automation;
use App\Models\AutomationRun;
use Illuminate\Database\Eloquent\Factories\Factory;
class AutomationRunFactory extends Factory
{
protected $model = AutomationRun::class;
public function definition(): array
{
return [
'automation_id' => Automation::factory(),
'status' => Status::Pending,
'is_manual' => false,
'is_dry_run' => false,
'context' => [],
];
}
public function running(string $nodeId = 'node_1'): static
{
return $this->state(fn () => [
'status' => Status::Running,
'current_node_id' => $nodeId,
'started_at' => now(),
]);
}
public function waiting(\DateTimeInterface $until): static
{
return $this->state(fn () => [
'status' => Status::Waiting,
'next_action_at' => $until,
]);
}
public function completed(): static
{
return $this->state(fn () => [
'status' => Status::Completed,
'finished_at' => now(),
]);
}
}

View file

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

View file

@ -25,7 +25,7 @@ public function definition(): array
'user_id' => User::factory(),
'name' => fake()->company(),
'content_language' => 'en',
'brand_tone' => 'professional',
'brand_voice_traits' => ['balanced', 'direct'],
'brand_font' => 'Inter',
'image_style' => 'cinematic',
];

View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('automations', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('workspace_id')->constrained('workspaces')->cascadeOnDelete();
$table->foreignUuid('user_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('name');
$table->string('status')->default('draft');
$table->string('trigger_type')->nullable();
$table->json('nodes')->nullable();
$table->json('connections')->nullable();
$table->json('variables')->nullable();
$table->timestamp('activated_at')->nullable();
$table->timestamp('paused_at')->nullable();
$table->timestamps();
$table->index(['workspace_id', 'status']);
$table->index(['status', 'trigger_type']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('automations');
}
};

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