diff --git a/app/Jobs/PublishPostJob.php b/app/Jobs/PublishPostJob.php index 8221846..8045d74 100644 --- a/app/Jobs/PublishPostJob.php +++ b/app/Jobs/PublishPostJob.php @@ -21,7 +21,7 @@ class PublishPostJob implements ShouldQueue public function handle(PublisherManager $publishers): void { - $post = Post::with(['site', 'socialAccount'])->find($this->postId); + $post = Post::with(['site', 'socialAccounts'])->find($this->postId); if (! $post) { return; @@ -34,6 +34,24 @@ class PublishPostJob implements ShouldQueue return; } + $results = []; + $errors = []; + $urls = []; + + // Get all social accounts that match the post's type + $socialAccounts = $post->socialAccounts->filter( + fn ($account) => $account->network === $post->type + ); + + if ($socialAccounts->isEmpty()) { + $post->update([ + 'status' => 'draft', + 'publish_error' => "No social accounts linked to this post for type '{$post->type}'.", + ]); + + return; + } + $publisher = $publishers->resolve($post); if (! $publisher) { @@ -42,19 +60,39 @@ class PublishPostJob implements ShouldQueue return; } - $result = $publisher->publish($post); + // Publish to each social account + foreach ($socialAccounts as $socialAccount) { + $result = $publisher->publish($post, $socialAccount); + $results[] = $result; - if ($result->success) { - $post->update(['status' => 'published', 'published_url' => $result->url, 'publish_error' => null]); + if ($result->success) { + $urls[] = $result->url; + } else { + $errors[] = $result->error; + } + } + + // If at least one succeeded, mark as published with the first URL + if (! empty($urls)) { + $post->update([ + 'status' => 'published', + 'published_url' => $urls[0], + 'publish_error' => ! empty($errors) ? implode('; ', $errors) : null, + ]); return; } + // All failed $post->update([ - 'status' => $result->attempted ? 'failed' : 'draft', - 'publish_error' => $result->error, + 'status' => 'failed', + 'published_url' => null, + 'publish_error' => implode('; ', $errors), ]); - Log::error('post publish failed', ['post_id' => $post->id, 'error' => $result->error]); + Log::error('post publish failed for all accounts', [ + 'post_id' => $post->id, + 'errors' => $errors, + ]); } } diff --git a/app/Livewire/Posts/Index.php b/app/Livewire/Posts/Index.php index 5892c78..a4b6657 100644 --- a/app/Livewire/Posts/Index.php +++ b/app/Livewire/Posts/Index.php @@ -22,7 +22,7 @@ class Index extends Component public string $siteId = ''; - public string $socialAccountId = ''; + public array $selectedAccountIds = []; public string $type = ''; @@ -42,7 +42,7 @@ class Index extends Component public function render() { - $posts = Post::with(['site', 'socialAccount'])->orderByDesc('created_at')->get(); + $posts = Post::with(['site', 'socialAccounts'])->orderByDesc('created_at')->get(); return view('livewire.posts.index', [ 'posts' => $posts, @@ -111,10 +111,10 @@ class Index extends Component public function edit(int $id): void { - $post = Post::findOrFail($id); + $post = Post::with('socialAccounts')->findOrFail($id); $this->editingId = $post->id; $this->siteId = (string) $post->site_id; - $this->socialAccountId = $post->social_account_id ? (string) $post->social_account_id : ''; + $this->selectedAccountIds = $post->socialAccounts->pluck('id')->map(fn ($id) => (string) $id)->toArray(); $this->type = $post->type; $this->title = (string) $post->title; $this->body = $post->body; @@ -129,7 +129,8 @@ class Index extends Component { $validated = $this->validate([ 'siteId' => ['required', 'integer', 'exists:sites,id'], - 'socialAccountId' => ['nullable', 'integer', 'exists:social_accounts,id'], + 'selectedAccountIds' => ['array'], + 'selectedAccountIds.*' => ['integer', 'exists:social_accounts,id'], 'type' => ['required', 'string', 'in:' . implode(',', Post::TYPES)], 'title' => ['nullable', 'string', 'max:255'], 'body' => ['required', 'string'], @@ -139,9 +140,8 @@ class Index extends Component 'scheduledAt' => ['nullable', 'date'], ]); - Post::updateOrCreate(['id' => $this->editingId], [ + $post = Post::updateOrCreate(['id' => $this->editingId], [ 'site_id' => $validated['siteId'], - 'social_account_id' => $validated['socialAccountId'] ?: null, 'type' => $validated['type'], 'title' => $validated['title'] ?: null, 'body' => $validated['body'], @@ -151,6 +151,13 @@ class Index extends Component 'scheduled_at' => $validated['scheduledAt'] ?: null, ]); + // Sync social accounts using pivot table + if ($this->editingId) { + $post->socialAccounts()->sync($validated['selectedAccountIds'] ?: []); + } else { + $post->socialAccounts()->attach($validated['selectedAccountIds'] ?: []); + } + $this->resetForm(); $this->showForm = false; } @@ -165,7 +172,7 @@ class Index extends Component $this->editingId = null; $this->templateId = null; $this->siteId = ''; - $this->socialAccountId = ''; + $this->selectedAccountIds = []; $this->type = ''; $this->title = ''; $this->body = ''; diff --git a/app/Models/Post.php b/app/Models/Post.php index fdf386a..0acd790 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -5,6 +5,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; class Post extends Model { @@ -19,7 +20,6 @@ class Post extends Model protected $fillable = [ 'site_id', - 'social_account_id', 'type', 'title', 'body', @@ -40,8 +40,8 @@ class Post extends Model return $this->belongsTo(Site::class); } - public function socialAccount(): BelongsTo + public function socialAccounts(): BelongsToMany { - return $this->belongsTo(SocialAccount::class); + return $this->belongsToMany(SocialAccount::class, 'post_social_account'); } } diff --git a/app/Support/Publishing/LinkedInPublisher.php b/app/Support/Publishing/LinkedInPublisher.php index 425536c..c3f7009 100644 --- a/app/Support/Publishing/LinkedInPublisher.php +++ b/app/Support/Publishing/LinkedInPublisher.php @@ -13,13 +13,14 @@ class LinkedInPublisher implements Publisher return $post->type === 'linkedin'; } - public function publish(Post $post): PublishResult + public function publish(Post $post, ?SocialAccount $socialAccount = null): PublishResult { if (! $post->body) { return PublishResult::rejected("Post {$post->id} has no body, required for a LinkedIn post."); } - $account = $post->socialAccount; + // Use the passed social account, or fall back to the first linked one + $account = $socialAccount ?: $post->socialAccounts->first(); if (! $account || $account->network !== 'linkedin') { return PublishResult::rejected("Post {$post->id} has no linked LinkedIn social account."); diff --git a/app/Support/Publishing/Publisher.php b/app/Support/Publishing/Publisher.php index fff79fd..bb91f2f 100644 --- a/app/Support/Publishing/Publisher.php +++ b/app/Support/Publishing/Publisher.php @@ -3,6 +3,7 @@ namespace App\Support\Publishing; use App\Models\Post; +use App\Models\SocialAccount; interface Publisher { @@ -12,5 +13,5 @@ interface Publisher */ public function supports(Post $post): bool; - public function publish(Post $post): PublishResult; + public function publish(Post $post, ?SocialAccount $socialAccount = null): PublishResult; } diff --git a/app/Support/Publishing/RedditPublisher.php b/app/Support/Publishing/RedditPublisher.php index a1bf82a..37e6099 100644 --- a/app/Support/Publishing/RedditPublisher.php +++ b/app/Support/Publishing/RedditPublisher.php @@ -13,7 +13,7 @@ class RedditPublisher implements Publisher return $post->type === 'reddit'; } - public function publish(Post $post): PublishResult + public function publish(Post $post, ?SocialAccount $socialAccount = null): PublishResult { if (! $post->title) { return PublishResult::rejected("Post {$post->id} has no title, required for a Reddit post."); @@ -23,7 +23,8 @@ class RedditPublisher implements Publisher return PublishResult::rejected("Post {$post->id} has no target subreddit set."); } - $account = $post->socialAccount; + // Use the passed social account, or fall back to the first linked one + $account = $socialAccount ?: $post->socialAccounts->first(); if (! $account || $account->network !== 'reddit') { return PublishResult::rejected("Post {$post->id} has no linked Reddit social account."); diff --git a/app/Support/Publishing/TwitterPublisher.php b/app/Support/Publishing/TwitterPublisher.php index ef6df12..2206256 100644 --- a/app/Support/Publishing/TwitterPublisher.php +++ b/app/Support/Publishing/TwitterPublisher.php @@ -13,13 +13,14 @@ class TwitterPublisher implements Publisher return $post->type === 'twitter'; } - public function publish(Post $post): PublishResult + public function publish(Post $post, ?SocialAccount $socialAccount = null): PublishResult { if (! $post->body) { return PublishResult::rejected("Post {$post->id} has no body, required for a Twitter post."); } - $account = $post->socialAccount; + // Use the passed social account, or fall back to the first linked one + $account = $socialAccount ?: $post->socialAccounts->first(); if (! $account || $account->network !== 'twitter') { return PublishResult::rejected("Post {$post->id} has no linked Twitter social account."); diff --git a/app/Support/Publishing/WordPressPublisher.php b/app/Support/Publishing/WordPressPublisher.php index c8542b6..b43a9d2 100644 --- a/app/Support/Publishing/WordPressPublisher.php +++ b/app/Support/Publishing/WordPressPublisher.php @@ -14,7 +14,7 @@ class WordPressPublisher implements Publisher return $post->type === 'blog'; } - public function publish(Post $post): PublishResult + public function publish(Post $post, ?SocialAccount $socialAccount = null): PublishResult { if (! $post->title) { return PublishResult::rejected("Post {$post->id} has no title, required to publish to WordPress."); diff --git a/database/migrations/2026_07_27_000001_create_post_social_account_table.php b/database/migrations/2026_07_27_000001_create_post_social_account_table.php new file mode 100644 index 0000000..e78d1eb --- /dev/null +++ b/database/migrations/2026_07_27_000001_create_post_social_account_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignId('post_id')->constrained()->cascadeOnDelete(); + $table->foreignId('social_account_id')->constrained()->cascadeOnDelete(); + $table->timestamps(); + + $table->unique(['post_id', 'social_account_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('post_social_account'); + } +}; diff --git a/database/migrations/2026_07_27_000002_migrate_social_account_to_pivot.php b/database/migrations/2026_07_27_000002_migrate_social_account_to_pivot.php new file mode 100644 index 0000000..a181cb1 --- /dev/null +++ b/database/migrations/2026_07_27_000002_migrate_social_account_to_pivot.php @@ -0,0 +1,33 @@ +whereNotNull('social_account_id') + ->orderBy('id') + ->chunk(100, function ($posts) { + $now = now(); + foreach ($posts as $post) { + DB::table('post_social_account')->insert([ + 'post_id' => $post->id, + 'social_account_id' => $post->social_account_id, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + }); + } + + public function down(): void + { + // On rollback, we can't easily restore the social_account_id column + // since we'll remove it in the next migration. This is fine for a fresh setup. + } +}; diff --git a/database/migrations/2026_07_27_000003_remove_social_account_id_from_posts.php b/database/migrations/2026_07_27_000003_remove_social_account_id_from_posts.php new file mode 100644 index 0000000..48cb982 --- /dev/null +++ b/database/migrations/2026_07_27_000003_remove_social_account_id_from_posts.php @@ -0,0 +1,26 @@ +dropForeign(['social_account_id']); + $table->dropColumn('social_account_id'); + }); + } + + public function down(): void + { + Schema::table('posts', function (Blueprint $table) { + $table->foreignId('social_account_id') + ->nullable() + ->constrained('social_accounts') + ->nullOnDelete(); + }); + } +}; diff --git a/resources/views/livewire/posts/index.blade.php b/resources/views/livewire/posts/index.blade.php index 713f708..dbcc316 100644 --- a/resources/views/livewire/posts/index.blade.php +++ b/resources/views/livewire/posts/index.blade.php @@ -45,7 +45,17 @@ @endif