Merge pull request #88 from trypostit/feat/automations-module
Automations ⚙️
This commit is contained in:
commit
44fb42b500
283 changed files with 17912 additions and 633 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
52
app/Actions/Automation/Automation/ActivateAutomation.php
Normal file
52
app/Actions/Automation/Automation/ActivateAutomation.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
53
app/Actions/Automation/Automation/CreateAutomation.php
Normal file
53
app/Actions/Automation/Automation/CreateAutomation.php
Normal 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'),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
15
app/Actions/Automation/Automation/DeleteAutomation.php
Normal file
15
app/Actions/Automation/Automation/DeleteAutomation.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
123
app/Actions/Automation/Automation/GetAutomationMetrics.php
Normal file
123
app/Actions/Automation/Automation/GetAutomationMetrics.php
Normal 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();
|
||||
}
|
||||
}
|
||||
20
app/Actions/Automation/Automation/ListAutomations.php
Normal file
20
app/Actions/Automation/Automation/ListAutomations.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
21
app/Actions/Automation/Automation/PauseAutomation.php
Normal file
21
app/Actions/Automation/Automation/PauseAutomation.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Automation\Automation;
|
||||
|
||||
use App\Enums\Automation\Status;
|
||||
use App\Models\Automation;
|
||||
|
||||
class PauseAutomation
|
||||
{
|
||||
public function __invoke(Automation $automation): Automation
|
||||
{
|
||||
$automation->update([
|
||||
'status' => Status::Paused,
|
||||
'paused_at' => now(),
|
||||
]);
|
||||
|
||||
return $automation;
|
||||
}
|
||||
}
|
||||
66
app/Actions/Automation/Automation/UpdateAutomation.php
Normal file
66
app/Actions/Automation/Automation/UpdateAutomation.php
Normal 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;
|
||||
}
|
||||
}
|
||||
64
app/Actions/Automation/Node/RunConditionNode.php
Normal file
64
app/Actions/Automation/Node/RunConditionNode.php
Normal 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;
|
||||
}
|
||||
}
|
||||
28
app/Actions/Automation/Node/RunDelayNode.php
Normal file
28
app/Actions/Automation/Node/RunDelayNode.php
Normal 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);
|
||||
}
|
||||
}
|
||||
23
app/Actions/Automation/Node/RunEndNode.php
Normal file
23
app/Actions/Automation/Node/RunEndNode.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Automation\Node;
|
||||
|
||||
use App\DataTransferObjects\Automation\NodeRunResult;
|
||||
use App\Models\AutomationRun;
|
||||
|
||||
class RunEndNode
|
||||
{
|
||||
public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
||||
{
|
||||
$reason = data_get($config, 'reason');
|
||||
|
||||
return NodeRunResult::completed(output: [
|
||||
'end' => [
|
||||
'ended_at' => now()->toIso8601String(),
|
||||
'reason' => $reason ?: null,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
215
app/Actions/Automation/Node/RunFetchRssNode.php
Normal file
215
app/Actions/Automation/Node/RunFetchRssNode.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
356
app/Actions/Automation/Node/RunGenerateNode.php
Normal file
356
app/Actions/Automation/Node/RunGenerateNode.php
Normal 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));
|
||||
}
|
||||
}
|
||||
471
app/Actions/Automation/Node/RunHttpRequestNode.php
Normal file
471
app/Actions/Automation/Node/RunHttpRequestNode.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
59
app/Actions/Automation/Node/RunPublishNode.php
Normal file
59
app/Actions/Automation/Node/RunPublishNode.php
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Automation\Node;
|
||||
|
||||
use App\DataTransferObjects\Automation\NodeRunResult;
|
||||
use App\Enums\Automation\Publish\Mode;
|
||||
use App\Enums\Post\Status as PostStatus;
|
||||
use App\Jobs\PublishPost;
|
||||
use App\Models\AutomationRun;
|
||||
use App\Models\Post;
|
||||
|
||||
class RunPublishNode
|
||||
{
|
||||
public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
||||
{
|
||||
$mode = Mode::from(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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
100
app/Actions/Automation/Node/RunWebhookNode.php
Normal file
100
app/Actions/Automation/Node/RunWebhookNode.php
Normal 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),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
79
app/Actions/Automation/Run/AdvanceAutomationRun.php
Normal file
79
app/Actions/Automation/Run/AdvanceAutomationRun.php
Normal 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);
|
||||
}
|
||||
}
|
||||
55
app/Actions/Automation/Run/DispatchAutomationRun.php
Normal file
55
app/Actions/Automation/Run/DispatchAutomationRun.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
28
app/Actions/Automation/Run/RetryRunFromNode.php
Normal file
28
app/Actions/Automation/Run/RetryRunFromNode.php
Normal 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);
|
||||
}
|
||||
}
|
||||
121
app/Actions/Automation/Run/TestAutomation.php
Normal file
121
app/Actions/Automation/Run/TestAutomation.php
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Automation\Run;
|
||||
|
||||
use App\Enums\Automation\Run\Status;
|
||||
use App\Enums\Automation\Trigger\Type as TriggerType;
|
||||
use App\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(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
36
app/Actions/Automation/Trigger/FireScheduleTrigger.php
Normal file
36
app/Actions/Automation/Trigger/FireScheduleTrigger.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Automation\Trigger;
|
||||
|
||||
use App\Actions\Automation\TriggerItem\EnrollTriggerItem;
|
||||
use App\Models\Automation;
|
||||
use Cron\CronExpression;
|
||||
|
||||
class FireScheduleTrigger
|
||||
{
|
||||
public function __construct(private EnrollTriggerItem $enroll) {}
|
||||
|
||||
public function __invoke(Automation $automation): bool
|
||||
{
|
||||
$triggerNode = collect($automation->nodes ?? [])->firstWhere('type', 'trigger');
|
||||
$cron = data_get($triggerNode, 'data.cron');
|
||||
$timezone = data_get($triggerNode, 'data.schedule_timezone', config('app.timezone'));
|
||||
|
||||
if ($cron === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$expression = new CronExpression($cron);
|
||||
|
||||
if (! $expression->isDue(now(), $timezone)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$key = now()->format('Y-m-d\TH:i');
|
||||
$payload = ['fired_at' => now()->toIso8601String()];
|
||||
|
||||
return ($this->enroll)($automation, $key, $payload) !== null;
|
||||
}
|
||||
}
|
||||
35
app/Actions/Automation/TriggerItem/EnrollTriggerItem.php
Normal file
35
app/Actions/Automation/TriggerItem/EnrollTriggerItem.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Automation\TriggerItem;
|
||||
|
||||
use App\Actions\Automation\Run\DispatchAutomationRun;
|
||||
use App\Models\Automation;
|
||||
use App\Models\AutomationRun;
|
||||
use App\Models\AutomationTriggerItem;
|
||||
|
||||
class EnrollTriggerItem
|
||||
{
|
||||
public function __construct(private DispatchAutomationRun $dispatchRun) {}
|
||||
|
||||
public function __invoke(Automation $automation, string $itemKey, array $payload): ?AutomationRun
|
||||
{
|
||||
$existing = AutomationTriggerItem::where('automation_id', $automation->id)
|
||||
->where('item_key', $itemKey)
|
||||
->first();
|
||||
|
||||
if ($existing !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$item = AutomationTriggerItem::create([
|
||||
'automation_id' => $automation->id,
|
||||
'item_key' => $itemKey,
|
||||
'payload' => $payload,
|
||||
'first_seen_at' => now(),
|
||||
]);
|
||||
|
||||
return ($this->dispatchRun)($automation, $item);
|
||||
}
|
||||
}
|
||||
|
|
@ -31,7 +31,7 @@ class CreatePost
|
|||
* media?: array<int, mixed>,
|
||||
* date?: ?string,
|
||||
* scheduled_at?: ?string,
|
||||
* platforms?: array<int, array{social_account_id: string, content_type?: string}>,
|
||||
* platforms?: array<int, array{social_account_id: string, content_type?: string, meta?: array<string, mixed>}>,
|
||||
* label_ids?: array<int, string>
|
||||
* } $data
|
||||
*/
|
||||
|
|
@ -62,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()
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
35
app/Ai/Agents/Concerns/ResolvesPlatformCopyBudget.php
Normal file
35
app/Ai/Agents/Concerns/ResolvesPlatformCopyBudget.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
16
app/Broadcasting/AutomationChannel.php
Normal file
16
app/Broadcasting/AutomationChannel.php
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Broadcasting;
|
||||
|
||||
use App\Models\Automation;
|
||||
use App\Models\User;
|
||||
|
||||
class AutomationChannel
|
||||
{
|
||||
public function join(User $user, Automation $automation): bool
|
||||
{
|
||||
return $automation->workspace->hasMember($user);
|
||||
}
|
||||
}
|
||||
32
app/Console/Commands/Automation/FireScheduleTriggers.php
Normal file
32
app/Console/Commands/Automation/FireScheduleTriggers.php
Normal 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;
|
||||
}
|
||||
}
|
||||
48
app/Console/Commands/Automation/ProcessAutomationDelays.php
Normal file
48
app/Console/Commands/Automation/ProcessAutomationDelays.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
34
app/DataTransferObjects/Automation/NodeRunResult.php
Normal file
34
app/DataTransferObjects/Automation/NodeRunResult.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\DataTransferObjects\Automation;
|
||||
|
||||
use App\Enums\Automation\NodeRun\Status;
|
||||
use DateTimeInterface;
|
||||
|
||||
class NodeRunResult
|
||||
{
|
||||
public function __construct(
|
||||
public readonly Status $status,
|
||||
public readonly array $output = [],
|
||||
public readonly string $nextHandle = 'default',
|
||||
public readonly ?DateTimeInterface $sleepUntil = null,
|
||||
public readonly ?array $error = null,
|
||||
) {}
|
||||
|
||||
public static function completed(array $output = [], string $nextHandle = 'default'): self
|
||||
{
|
||||
return new self(Status::Completed, $output, $nextHandle);
|
||||
}
|
||||
|
||||
public static function sleep(DateTimeInterface $until): self
|
||||
{
|
||||
return new self(Status::Completed, sleepUntil: $until);
|
||||
}
|
||||
|
||||
public static function failed(string $message, ?array $extra = null): self
|
||||
{
|
||||
return new self(Status::Failed, error: array_merge(['message' => $message], $extra ?? []));
|
||||
}
|
||||
}
|
||||
17
app/Enums/Automation/AuthType.php
Normal file
17
app/Enums/Automation/AuthType.php
Normal 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';
|
||||
}
|
||||
17
app/Enums/Automation/Condition/Handle.php
Normal file
17
app/Enums/Automation/Condition/Handle.php
Normal 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';
|
||||
}
|
||||
16
app/Enums/Automation/Condition/Operator.php
Normal file
16
app/Enums/Automation/Condition/Operator.php
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums\Automation\Condition;
|
||||
|
||||
enum Operator: string
|
||||
{
|
||||
case Contains = 'contains';
|
||||
case NotContains = 'not_contains';
|
||||
case Equals = 'equals';
|
||||
case NotEquals = 'not_equals';
|
||||
case Matches = 'matches';
|
||||
case GreaterThan = 'greater_than';
|
||||
case LessThan = 'less_than';
|
||||
}
|
||||
16
app/Enums/Automation/DelayUnit.php
Normal file
16
app/Enums/Automation/DelayUnit.php
Normal 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';
|
||||
}
|
||||
28
app/Enums/Automation/HttpMethod.php
Normal file
28
app/Enums/Automation/HttpMethod.php
Normal 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];
|
||||
}
|
||||
}
|
||||
18
app/Enums/Automation/Node/Type.php
Normal file
18
app/Enums/Automation/Node/Type.php
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums\Automation\Node;
|
||||
|
||||
enum Type: string
|
||||
{
|
||||
case Trigger = 'trigger';
|
||||
case Generate = 'generate';
|
||||
case Delay = 'delay';
|
||||
case Condition = 'condition';
|
||||
case Publish = 'publish';
|
||||
case Webhook = 'webhook';
|
||||
case End = 'end';
|
||||
case FetchRss = 'fetch_rss';
|
||||
case HttpRequest = 'http_request';
|
||||
}
|
||||
13
app/Enums/Automation/NodeRun/Status.php
Normal file
13
app/Enums/Automation/NodeRun/Status.php
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums\Automation\NodeRun;
|
||||
|
||||
enum Status: string
|
||||
{
|
||||
case Running = 'running';
|
||||
case Completed = 'completed';
|
||||
case Failed = 'failed';
|
||||
case Skipped = 'skipped';
|
||||
}
|
||||
12
app/Enums/Automation/Publish/Mode.php
Normal file
12
app/Enums/Automation/Publish/Mode.php
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums\Automation\Publish;
|
||||
|
||||
enum Mode: string
|
||||
{
|
||||
case Now = 'now';
|
||||
case Scheduled = 'scheduled';
|
||||
case Draft = 'draft';
|
||||
}
|
||||
15
app/Enums/Automation/Run/Status.php
Normal file
15
app/Enums/Automation/Run/Status.php
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums\Automation\Run;
|
||||
|
||||
enum Status: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case Running = 'running';
|
||||
case Waiting = 'waiting';
|
||||
case Completed = 'completed';
|
||||
case Failed = 'failed';
|
||||
case Cancelled = 'cancelled';
|
||||
}
|
||||
18
app/Enums/Automation/ScheduleField.php
Normal file
18
app/Enums/Automation/ScheduleField.php
Normal 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';
|
||||
}
|
||||
12
app/Enums/Automation/Status.php
Normal file
12
app/Enums/Automation/Status.php
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums\Automation;
|
||||
|
||||
enum Status: string
|
||||
{
|
||||
case Draft = 'draft';
|
||||
case Active = 'active';
|
||||
case Paused = 'paused';
|
||||
}
|
||||
12
app/Enums/Automation/Trigger/Type.php
Normal file
12
app/Enums/Automation/Trigger/Type.php
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums\Automation\Trigger;
|
||||
|
||||
enum Type: string
|
||||
{
|
||||
case Schedule = 'schedule';
|
||||
case PostPublished = 'post_published';
|
||||
case PostScheduled = 'post_scheduled';
|
||||
}
|
||||
158
app/Enums/Workspace/BrandVoiceTrait.php
Normal file
158
app/Enums/Workspace/BrandVoiceTrait.php
Normal 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;
|
||||
}
|
||||
}
|
||||
54
app/Events/AutomationRunUpdated.php
Normal file
54
app/Events/AutomationRunUpdated.php
Normal 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';
|
||||
}
|
||||
}
|
||||
228
app/Http/Controllers/App/AutomationController.php
Normal file
228
app/Http/Controllers/App/AutomationController.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\App\Automations;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ActivateAutomationRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
23
app/Http/Requests/App/Automations/PauseAutomationRequest.php
Normal file
23
app/Http/Requests/App/Automations/PauseAutomationRequest.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\App\Automations;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class PauseAutomationRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
25
app/Http/Requests/App/Automations/RetryRunRequest.php
Normal file
25
app/Http/Requests/App/Automations/RetryRunRequest.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\App\Automations;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class RetryRunRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'node_id' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
}
|
||||
23
app/Http/Requests/App/Automations/StoreAutomationRequest.php
Normal file
23
app/Http/Requests/App/Automations/StoreAutomationRequest.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
25
app/Http/Requests/App/Automations/TestAutomationRequest.php
Normal file
25
app/Http/Requests/App/Automations/TestAutomationRequest.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\App\Automations;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class TestAutomationRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'with_real_data' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
182
app/Http/Requests/App/Automations/UpdateAutomationRequest.php
Normal file
182
app/Http/Requests/App/Automations/UpdateAutomationRequest.php
Normal 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 => [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
33
app/Http/Resources/AutomationInvocationResource.php
Normal file
33
app/Http/Resources/AutomationInvocationResource.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
29
app/Http/Resources/AutomationNodeRunResource.php
Normal file
29
app/Http/Resources/AutomationNodeRunResource.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class AutomationNodeRunResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'node_id' => $this->node_id,
|
||||
'node_type' => $this->node_type->value,
|
||||
'status' => $this->status->value,
|
||||
'input' => $this->input,
|
||||
'output' => $this->output,
|
||||
'error' => $this->error,
|
||||
'started_at' => $this->started_at,
|
||||
'finished_at' => $this->finished_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
74
app/Http/Resources/AutomationResource.php
Normal file
74
app/Http/Resources/AutomationResource.php
Normal 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;
|
||||
}
|
||||
}
|
||||
33
app/Http/Resources/AutomationRunResource.php
Normal file
33
app/Http/Resources/AutomationRunResource.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class AutomationRunResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'automation_id' => $this->automation_id,
|
||||
'trigger_item_id' => $this->trigger_item_id,
|
||||
'current_node_id' => $this->current_node_id,
|
||||
'status' => $this->status->value,
|
||||
'is_manual' => (bool) $this->is_manual,
|
||||
'is_dry_run' => (bool) $this->is_dry_run,
|
||||
'next_action_at' => $this->next_action_at,
|
||||
'generated_post_id' => $this->generated_post_id,
|
||||
'context' => $this->context,
|
||||
'error' => $this->error,
|
||||
'started_at' => $this->started_at,
|
||||
'finished_at' => $this->finished_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
25
app/Http/Resources/AutomationTriggerItemResource.php
Normal file
25
app/Http/Resources/AutomationTriggerItemResource.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class AutomationTriggerItemResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'item_key' => $this->item_key,
|
||||
'payload' => $this->payload,
|
||||
'first_seen_at' => $this->first_seen_at,
|
||||
'run' => AutomationRunResource::make($this->whenLoaded('run')),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
33
app/Jobs/Automation/DispatchPostTriggerAutomationsJob.php
Normal file
33
app/Jobs/Automation/DispatchPostTriggerAutomationsJob.php
Normal 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);
|
||||
}
|
||||
}
|
||||
155
app/Jobs/Automation/ProcessAutomationNode.php
Normal file
155
app/Jobs/Automation/ProcessAutomationNode.php
Normal 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
214
app/Models/Automation.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
38
app/Models/AutomationNodeRun.php
Normal file
38
app/Models/AutomationNodeRun.php
Normal 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');
|
||||
}
|
||||
}
|
||||
40
app/Models/AutomationNodeState.php
Normal file
40
app/Models/AutomationNodeState.php
Normal 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' => []],
|
||||
);
|
||||
}
|
||||
}
|
||||
106
app/Models/AutomationRun.php
Normal file
106
app/Models/AutomationRun.php
Normal 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);
|
||||
}
|
||||
}
|
||||
34
app/Models/AutomationTriggerItem.php
Normal file
34
app/Models/AutomationTriggerItem.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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> */
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
23
app/Observers/AutomationNodeRunObserver.php
Normal file
23
app/Observers/AutomationNodeRunObserver.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Events\AutomationRunUpdated;
|
||||
use App\Models\AutomationNodeRun;
|
||||
|
||||
class AutomationNodeRunObserver
|
||||
{
|
||||
public function created(AutomationNodeRun $nodeRun): void
|
||||
{
|
||||
AutomationRunUpdated::dispatch($nodeRun->run);
|
||||
}
|
||||
|
||||
public function updated(AutomationNodeRun $nodeRun): void
|
||||
{
|
||||
if ($nodeRun->wasChanged(['status', 'output', 'error', 'finished_at'])) {
|
||||
AutomationRunUpdated::dispatch($nodeRun->run);
|
||||
}
|
||||
}
|
||||
}
|
||||
25
app/Observers/AutomationRunObserver.php
Normal file
25
app/Observers/AutomationRunObserver.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Events\AutomationRunUpdated;
|
||||
use App\Models\AutomationRun;
|
||||
|
||||
class AutomationRunObserver
|
||||
{
|
||||
public function created(AutomationRun $run): void
|
||||
{
|
||||
AutomationRunUpdated::dispatch($run);
|
||||
}
|
||||
|
||||
public function updated(AutomationRun $run): void
|
||||
{
|
||||
// Only broadcast when the user-visible state changes — skip noisy
|
||||
// updates like `context` mutations from intermediate node merges.
|
||||
if ($run->wasChanged(['status', 'current_node_id', 'finished_at', 'next_action_at', 'error'])) {
|
||||
AutomationRunUpdated::dispatch($run);
|
||||
}
|
||||
}
|
||||
}
|
||||
32
app/Observers/PostObserver.php
Normal file
32
app/Observers/PostObserver.php
Normal 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();
|
||||
}
|
||||
}
|
||||
46
app/Policies/AutomationPolicy.php
Normal file
46
app/Policies/AutomationPolicy.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Automation;
|
||||
use App\Models\User;
|
||||
|
||||
class AutomationPolicy
|
||||
{
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->currentWorkspace !== null;
|
||||
}
|
||||
|
||||
public function view(User $user, Automation $automation): bool
|
||||
{
|
||||
return $automation->workspace_id === $user->current_workspace_id;
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->currentWorkspace !== null;
|
||||
}
|
||||
|
||||
public function update(User $user, Automation $automation): bool
|
||||
{
|
||||
return $automation->workspace_id === $user->current_workspace_id;
|
||||
}
|
||||
|
||||
public function delete(User $user, Automation $automation): bool
|
||||
{
|
||||
return $automation->workspace_id === $user->current_workspace_id;
|
||||
}
|
||||
|
||||
public function activate(User $user, Automation $automation): bool
|
||||
{
|
||||
return $this->update($user, $automation);
|
||||
}
|
||||
|
||||
public function pause(User $user, Automation $automation): bool
|
||||
{
|
||||
return $this->update($user, $automation);
|
||||
}
|
||||
}
|
||||
59
app/Services/Automation/AutomationConfigValidator.php
Normal file
59
app/Services/Automation/AutomationConfigValidator.php
Normal 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;
|
||||
}
|
||||
}
|
||||
65
app/Services/Automation/ExpressionResolver.php
Normal file
65
app/Services/Automation/ExpressionResolver.php
Normal 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) ?: '';
|
||||
}
|
||||
}
|
||||
73
app/Services/Automation/GenerateNodeValidator.php
Normal file
73
app/Services/Automation/GenerateNodeValidator.php
Normal 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;
|
||||
}
|
||||
}
|
||||
36
app/Services/Automation/WebhookNodeValidator.php
Normal file
36
app/Services/Automation/WebhookNodeValidator.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 : []),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
122
app/Services/Image/PostImagePipeline.php
Normal file
122
app/Services/Image/PostImagePipeline.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
62
database/factories/AutomationFactory.php
Normal file
62
database/factories/AutomationFactory.php
Normal 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' => [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
28
database/factories/AutomationNodeRunFactory.php
Normal file
28
database/factories/AutomationNodeRunFactory.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
23
database/factories/AutomationNodeStateFactory.php
Normal file
23
database/factories/AutomationNodeStateFactory.php
Normal 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' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
51
database/factories/AutomationRunFactory.php
Normal file
51
database/factories/AutomationRunFactory.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
27
database/factories/AutomationTriggerItemFactory.php
Normal file
27
database/factories/AutomationTriggerItemFactory.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue