Merge pull request #203 from trypostit/feature/post-created-via

Track post creation origin with created_via
This commit is contained in:
Paulo Castellano 2026-07-24 12:29:47 -03:00 committed by GitHub
commit 596ad491ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 498 additions and 7 deletions

View file

@ -12,6 +12,7 @@
use App\DataTransferObjects\Automation\NodeRunResult;
use App\Enums\Ai\ContentStyle;
use App\Enums\Ai\GeneratorFormat;
use App\Enums\Post\CreatedVia;
use App\Enums\PostPlatform\ContentType;
use App\Models\AutomationRun;
use App\Models\SocialAccount;
@ -145,6 +146,7 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
'content' => $generated->content,
'media' => $generated->media,
'platforms' => $platforms,
'created_via' => CreatedVia::Automation,
]);
$run->update(['generated_post_id' => $post->id]);

View file

@ -4,8 +4,8 @@
namespace App\Actions\Post;
use App\Enums\Post\CreatedVia;
use App\Enums\Post\Status as PostStatus;
use App\Events\PostCreated;
use App\Models\Post;
use App\Models\User;
use App\Models\Workspace;
@ -26,11 +26,15 @@ class CreatePost
* `label_ids[]` are attached after creation so the same set of UUIDs
* works for REST, MCP, and web callers.
*
* `created_via` records which entry point created the post (web, mcp,
* api, or automation). Analytical only — null when omitted.
*
* @param array{
* content?: ?string,
* media?: array<int, mixed>,
* date?: ?string,
* scheduled_at?: ?string,
* created_via?: ?CreatedVia,
* platforms?: array<int, array{social_account_id: string, content_type?: string, meta?: array<string, mixed>}>,
* label_ids?: array<int, string>
* } $data
@ -45,6 +49,7 @@ public static function execute(Workspace $workspace, User $user, array $data): P
'content' => data_get($data, 'content', ''),
'media' => data_get($data, 'media', []),
'status' => PostStatus::Draft,
'created_via' => data_get($data, 'created_via'),
'scheduled_at' => $scheduledAt,
]);
@ -85,8 +90,6 @@ public static function execute(Workspace $workspace, User $user, array $data): P
return $post;
});
PostCreated::dispatch($post);
return $post;
}

View file

@ -4,6 +4,7 @@
namespace App\Actions\Post;
use App\Enums\Post\CreatedVia;
use App\Enums\Post\Status as PostStatus;
use App\Enums\PostPlatform\Status as PostPlatformStatus;
use App\Models\Post;
@ -25,6 +26,7 @@ public static function execute(Post $original, User $user): Post
'content' => $original->content,
'media' => $original->media,
'status' => PostStatus::Draft,
'created_via' => CreatedVia::Web,
'scheduled_at' => null,
'published_at' => null,
]);

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Enums\Post;
enum CreatedVia: string
{
case Web = 'web';
case Mcp = 'mcp';
case Api = 'api';
case Automation = 'automation';
}

View file

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Enums\PostHog;
enum PostEvent: string
{
case Created = 'post.created';
}

View file

@ -10,6 +10,7 @@
use App\Actions\Post\UpdatePost;
use App\Enums\Media\Type as MediaType;
use App\Enums\Post\Action as PostAction;
use App\Enums\Post\CreatedVia;
use App\Http\Requests\Api\Post\AttachMediaFromUrlRequest;
use App\Http\Requests\Api\Post\StoreMediaRequest;
use App\Http\Requests\Api\Post\StorePostRequest;
@ -61,6 +62,8 @@ public function store(StorePostRequest $request): JsonResponse
);
}
$data['created_via'] = CreatedVia::Api;
$post = CreatePost::execute($workspace, $workspace->owner, $data);
$post->load(['postPlatforms.socialAccount']);

View file

@ -12,6 +12,7 @@
use App\Ai\Templates\AiContentTemplate;
use App\Ai\Templates\AiTemplateRegistry;
use App\Enums\Post\Action as PostAction;
use App\Enums\Post\CreatedVia;
use App\Enums\Post\Status as PostStatus;
use App\Enums\SocialAccount\Platform;
use App\Http\Requests\App\Post\StorePostRequest;
@ -189,6 +190,7 @@ public function store(StorePostRequest $request): RedirectResponse|\Symfony\Comp
$post = CreatePost::execute($workspace, $request->user(), [
'date' => $request->input('date'),
'media' => $request->input('media', []),
'created_via' => CreatedVia::Web,
]);
return Inertia::location(route('app.posts.edit', $post));

View file

@ -6,6 +6,7 @@
use App\Actions\Post\CreatePost;
use App\Enums\Media\Type as MediaType;
use App\Enums\Post\CreatedVia;
use App\Http\Requests\App\PostTemplate\ApplyPostTemplateRequest;
use App\Http\Requests\App\PostTemplate\IndexPostTemplateRequest;
use App\Http\Resources\App\PostTemplateResource;
@ -89,6 +90,7 @@ public function apply(ApplyPostTemplateRequest $request, string $slug, TemplateI
'content' => $content,
'media' => $media,
'date' => $request->input('date'),
'created_via' => CreatedVia::Web,
]);
return response()->json([

View file

@ -14,6 +14,7 @@
use App\Enums\Ai\GeneratorFormat;
use App\Enums\Notification\Channel as NotificationChannel;
use App\Enums\Notification\Type as NotificationType;
use App\Enums\Post\CreatedVia;
use App\Enums\PostPlatform\ContentType;
use App\Events\Ai\PostCreationReady;
use App\Jobs\SendNotification;
@ -195,6 +196,7 @@ private function createPostFromGenerated(Workspace $workspace, GeneratedPost $ge
'content' => $generated->content,
'media' => $generated->media,
'date' => $this->date,
'created_via' => CreatedVia::Web,
]);
if ($generated->contentType && $socialAccount) {

View file

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Jobs\PostHog;
use App\Enums\PostHog\PostEvent;
use App\Models\Post;
use App\Services\PostHogService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class TrackPost implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 30;
public function __construct(public string $postId)
{
$this->onQueue('posthog');
}
public function handle(PostHogService $postHog): void
{
if (! PostHogService::isEnabled()) {
return;
}
$post = Post::query()
->with(['workspace.account.plan'])
->find($this->postId);
if (! $post) {
return;
}
$account = $post->workspace?->account;
if (! $account) {
return;
}
$distinctId = (string) ($post->user_id ?? $account->owner_id);
if ($distinctId === '') {
return;
}
$postHog->capture(
$distinctId,
PostEvent::Created->value,
[
'post_id' => (string) $post->id,
'workspace_id' => (string) $post->workspace_id,
'created_via' => $post->created_via?->value,
'status' => $post->status->value,
],
$account,
);
}
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Listeners\PostHog;
use App\Events\PostCreated;
use App\Jobs\PostHog\TrackPost;
use App\Services\PostHogService;
class TrackPostCreated
{
public function handle(PostCreated $event): void
{
if (! PostHogService::isEnabled()) {
return;
}
TrackPost::dispatch((string) $event->post->id);
}
}

View file

@ -5,6 +5,7 @@
namespace App\Mcp\Tools\Post;
use App\Actions\Post\CreatePost;
use App\Enums\Post\CreatedVia;
use App\Enums\PostPlatform\ContentType;
use App\Http\Resources\Api\PostResource;
use App\Rules\ContentTypeMatchesPlatform;
@ -41,6 +42,8 @@ public function handle(Request $request): ResponseFactory
...PostPlatformMetaRules::rules(),
]);
$validated['created_via'] = CreatedVia::Mcp;
$post = CreatePost::execute($workspace, $request->user(), $validated);
$post->load(['postPlatforms.socialAccount', 'labels']);

View file

@ -6,6 +6,7 @@
use App\DataTransferObjects\MediaItem;
use App\Enums\Media\Type;
use App\Enums\Post\CreatedVia;
use App\Enums\Post\Status as PostStatus;
use App\Enums\SocialAccount\Platform;
use App\Observers\PostObserver;
@ -34,6 +35,7 @@ class Post extends Model
'content',
'media',
'status',
'created_via',
'scheduled_at',
'published_at',
];
@ -42,6 +44,7 @@ protected function casts(): array
{
return [
'status' => PostStatus::class,
'created_via' => CreatedVia::class,
'media' => 'array',
'scheduled_at' => 'datetime',
'published_at' => 'datetime',

View file

@ -6,11 +6,18 @@
use App\Enums\Automation\Trigger\Type as TriggerType;
use App\Enums\Post\Status as PostStatus;
use App\Events\PostCreated;
use App\Jobs\Automation\DispatchPostTriggerAutomationsJob;
use App\Models\Post;
use Illuminate\Support\Facades\DB;
class PostObserver
{
public function created(Post $post): void
{
DB::afterCommit(fn () => PostCreated::dispatch($post));
}
public function saved(Post $post): void
{
if (! $post->wasChanged('status')) {

View file

@ -0,0 +1,30 @@
<?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::table('posts', function (Blueprint $table) {
$table->string('created_via')->nullable()->after('status');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('posts', function (Blueprint $table) {
$table->dropColumn('created_via');
});
}
};

View file

@ -3,12 +3,13 @@
declare(strict_types=1);
use App\Actions\Post\CreatePost;
use App\Enums\Post\CreatedVia;
use App\Events\PostCreated;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Event;
test('execute dispatches PostCreated with the persisted post', function () {
test('execute relies on the observer to dispatch PostCreated', function () {
Event::fake([PostCreated::class]);
$user = User::factory()->create();
@ -16,6 +17,7 @@
$post = CreatePost::execute($workspace, $user, [
'content' => 'Hello world',
'created_via' => CreatedVia::Web,
]);
Event::assertDispatched(
@ -24,3 +26,31 @@
&& $event->post->workspace_id === $workspace->id,
);
});
test('execute persists created_via for each entry point', function (CreatedVia $createdVia) {
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$post = CreatePost::execute($workspace, $user, [
'content' => 'Hello world',
'created_via' => $createdVia,
]);
expect($post->fresh()->created_via)->toBe($createdVia);
})->with([
'web' => CreatedVia::Web,
'mcp' => CreatedVia::Mcp,
'api' => CreatedVia::Api,
'automation' => CreatedVia::Automation,
]);
test('execute leaves created_via null when omitted', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$post = CreatePost::execute($workspace, $user, [
'content' => 'Hello world',
]);
expect($post->fresh()->created_via)->toBeNull();
});

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
use App\Actions\Post\DuplicatePost;
use App\Enums\Post\CreatedVia;
use App\Enums\Post\Status as PostStatus;
use App\Events\PostCreated;
use App\Models\Post;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Event;
test('execute clones the post as a draft created via web', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$original = Post::factory()->create([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
'content' => 'Original content',
'created_via' => CreatedVia::Api,
'status' => PostStatus::Published,
]);
$copy = DuplicatePost::execute($original, $user);
expect($copy->id)->not->toBe($original->id)
->and($copy->content)->toBe('Original content')
->and($copy->status)->toBe(PostStatus::Draft)
->and($copy->created_via)->toBe(CreatedVia::Web)
->and($copy->scheduled_at)->toBeNull()
->and($copy->published_at)->toBeNull();
});
test('execute relies on the observer to dispatch PostCreated for the duplicate', function () {
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$original = Post::factory()->create([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
]);
Event::fake([PostCreated::class]);
$copy = DuplicatePost::execute($original, $user);
Event::assertDispatched(
PostCreated::class,
fn (PostCreated $event) => $event->post->id === $copy->id,
);
});

View file

@ -4,6 +4,7 @@
use App\Ai\Agents\PostContentGenerator;
use App\Ai\Agents\PostContentHumanizer;
use App\Enums\Post\CreatedVia;
use App\Enums\PostPlatform\ContentType;
use App\Enums\UserWorkspace\Role;
use App\Jobs\Ai\StreamPostCreation;
@ -111,7 +112,8 @@ function runStreamPostCreation(string $format, SocialAccount $account, int $imag
$post = $this->user->currentWorkspace->posts()->latest()->first();
expect($post->content)->toBe('Hello world\n\nSecond para.')
->and($post->media)->toHaveCount(1);
->and($post->media)->toHaveCount(1)
->and($post->created_via)->toBe(CreatedVia::Web);
$platform = PostPlatform::where('social_account_id', $this->account->id)->firstOrFail();

View file

@ -2,6 +2,7 @@
declare(strict_types=1);
use App\Enums\Post\CreatedVia;
use App\Enums\Post\Status as PostStatus;
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
@ -72,7 +73,26 @@
->assertCreated()
->assertJsonPath('status', PostStatus::Draft->value);
expect(Post::where('workspace_id', $this->workspace->id)->count())->toBe(1);
$post = Post::where('workspace_id', $this->workspace->id)->first();
expect($post)->not->toBeNull();
expect($post->created_via)->toBe(CreatedVia::Api);
});
it('ignores a client-supplied created_via and always records api', function () {
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.store'), [
'created_via' => 'web',
'platforms' => [
[
'social_account_id' => $this->socialAccount->id,
'content_type' => 'linkedin_post',
],
],
])
->assertCreated();
$post = Post::where('workspace_id', $this->workspace->id)->first();
expect($post->created_via)->toBe(CreatedVia::Api);
});
it('creates a post with content, media, and labels', function () {

View file

@ -7,6 +7,7 @@
use App\Ai\Agents\PostContentHumanizer;
use App\Enums\Ai\ContentStyle;
use App\Enums\Ai\GeneratorFormat;
use App\Enums\Post\CreatedVia;
use App\Enums\Post\Status as PostStatus;
use App\Enums\PostPlatform\ContentType;
use App\Models\Automation;
@ -47,6 +48,7 @@
$post = Post::find($run->generated_post_id);
expect($post)->not->toBeNull();
expect($post->status)->toBe(PostStatus::Draft);
expect($post->created_via)->toBe(CreatedVia::Automation);
});
it('applies brand voice by default', function () {

View file

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
use App\Enums\Post\CreatedVia;
use App\Enums\PostHog\PostEvent;
use App\Jobs\PostHog\SendEvent;
use App\Jobs\PostHog\TrackPost;
use App\Models\Account;
use App\Models\Post;
use App\Models\User;
use App\Models\Workspace;
use App\Services\PostHogService;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
beforeEach(function () {
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test_key']);
$this->account = Account::factory()->create();
$this->user = User::factory()->create(['account_id' => $this->account->id]);
$this->account->update(['owner_id' => $this->user->id]);
$this->workspace = Workspace::factory()->create([
'account_id' => $this->account->id,
'user_id' => $this->user->id,
]);
});
test('job is queued on the posthog queue', function () {
$job = new TrackPost((string) Str::uuid());
expect($job->queue)->toBe('posthog');
});
test('handle captures post.created with created_via and account group', function () {
Queue::fake();
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'created_via' => CreatedVia::Api,
]);
(new TrackPost((string) $post->id))->handle(app(PostHogService::class));
Queue::assertPushed(SendEvent::class, function ($job) use ($post) {
return $job->method === 'capture'
&& $job->payload['event'] === PostEvent::Created->value
&& $job->payload['distinctId'] === (string) $this->user->id
&& $job->payload['properties']['post_id'] === (string) $post->id
&& $job->payload['properties']['workspace_id'] === (string) $this->workspace->id
&& $job->payload['properties']['created_via'] === CreatedVia::Api->value
&& $job->payload['properties']['$groups']['account'] === (string) $this->account->id;
});
});
test('handle falls back to account owner when post has no user', function () {
Queue::fake();
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => null,
'created_via' => CreatedVia::Mcp,
]);
(new TrackPost((string) $post->id))->handle(app(PostHogService::class));
Queue::assertPushed(
SendEvent::class,
fn ($job) => $job->payload['distinctId'] === (string) $this->user->id
&& $job->payload['properties']['created_via'] === CreatedVia::Mcp->value,
);
});
test('handle returns silently when post does not exist', function () {
Queue::fake();
(new TrackPost((string) Str::uuid()))->handle(app(PostHogService::class));
Queue::assertNothingPushed();
});
test('handle does not push when PostHog is disabled', function () {
config(['services.posthog.api_key' => null]);
Queue::fake();
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'created_via' => CreatedVia::Web,
]);
(new TrackPost((string) $post->id))->handle(app(PostHogService::class));
Queue::assertNotPushed(SendEvent::class);
});

View file

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
use App\Enums\Post\CreatedVia;
use App\Events\PostCreated;
use App\Jobs\PostHog\TrackPost;
use App\Listeners\PostHog\TrackPostCreated;
use App\Models\Account;
use App\Models\Post;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Bus;
beforeEach(function () {
config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test_key']);
$this->account = Account::factory()->create();
$this->user = User::factory()->create(['account_id' => $this->account->id]);
$this->account->update(['owner_id' => $this->user->id]);
$this->workspace = Workspace::factory()->create([
'account_id' => $this->account->id,
'user_id' => $this->user->id,
]);
});
test('listener dispatches TrackPost with the post id', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'created_via' => CreatedVia::Web,
]);
Bus::fake();
(new TrackPostCreated)->handle(new PostCreated($post));
Bus::assertDispatched(
TrackPost::class,
fn ($job) => $job->postId === (string) $post->id,
);
});
test('listener is wired to the PostCreated event via auto-discovery', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'created_via' => CreatedVia::Automation,
]);
Bus::fake();
PostCreated::dispatch($post);
Bus::assertDispatched(TrackPost::class);
});
test('listener does not dispatch when PostHog is disabled', function () {
config(['services.posthog.enabled' => false]);
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
Bus::fake();
(new TrackPostCreated)->handle(new PostCreated($post));
Bus::assertNotDispatched(TrackPost::class);
});

View file

@ -2,6 +2,7 @@
declare(strict_types=1);
use App\Enums\Post\CreatedVia;
use App\Enums\SocialAccount\Platform;
use App\Enums\UserWorkspace\Role;
use App\Mcp\Servers\TryPostServer;
@ -109,7 +110,9 @@
->etc();
});
expect(Post::where('workspace_id', $this->workspace->id)->count())->toBe(1);
$post = Post::where('workspace_id', $this->workspace->id)->first();
expect($post)->not->toBeNull();
expect($post->created_via)->toBe(CreatedVia::Mcp);
});
test('create post with platforms enables only those', function () {

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use App\Events\PostCreated;
use App\Models\Post;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Event;
test('creating a post dispatches PostCreated via the observer', function () {
Event::fake([PostCreated::class]);
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
$post = Post::factory()->create([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
]);
Event::assertDispatched(
PostCreated::class,
fn (PostCreated $event) => $event->post->id === $post->id,
);
});
test('creating a post quietly does not dispatch PostCreated', function () {
Event::fake([PostCreated::class]);
$user = User::factory()->create();
$workspace = Workspace::factory()->create(['user_id' => $user->id]);
Post::factory()->createQuietly([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
]);
Event::assertNotDispatched(PostCreated::class);
});

View file

@ -2,6 +2,7 @@
declare(strict_types=1);
use App\Enums\Post\CreatedVia;
use App\Enums\Post\Status as PostStatus;
use App\Enums\PostPlatform\ContentType;
use App\Enums\PostPlatform\Status;
@ -257,6 +258,7 @@
$post = Post::where('workspace_id', $this->workspace->id)->first();
expect($post)->not->toBeNull();
expect($post->status)->toBe(PostStatus::Draft);
expect($post->created_via)->toBe(CreatedVia::Web);
expect($post->postPlatforms)->toHaveCount(1);
});

View file

@ -2,6 +2,7 @@
declare(strict_types=1);
use App\Enums\Post\CreatedVia;
use App\Enums\UserWorkspace\Role;
use App\Models\Account;
use App\Models\SocialAccount;
@ -72,6 +73,9 @@
expect($response->json('post_id'))->toBeString();
expect($response->json('redirect_url'))->toContain('edit');
$post = $this->workspace->posts()->find($response->json('post_id'));
expect($post->created_via)->toBe(CreatedVia::Web);
});
test('apply interpolates brand_name in content', function () {