From f8100075874c7e3747081e8005710f9e27751bcd Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 8 Oct 2026 20:00:23 -0300 Subject: [PATCH 01/54] Merge post_platforms into posts: schema, model and application code Every post now carries its single destination (social account, content type, meta) and its publication result (publish_status, platform post id, URL, error, checkpoints) on its own row. The migrations resolve the legacy rows TryPost 2.0's split left behind, refuse to run while a post still has several destinations, copy each destination onto its post, link analytics publications by post, and drop post_platforms. Code that only existed for posts with several destinations is removed instead of ported, together with the one-off TryPost 2.0 release scripts. --- .../Analytics/BuildChannelMetricSeries.php | 4 +- .../BuildPublicationAnalyticsReport.php | 7 +- .../ListChannelPublicationPerformance.php | 6 +- .../Analytics/ReadPublicationAnalytics.php | 82 +- .../Analytics/SyncTryPostPublication.php | 28 +- .../Analytics/UpsertAnalyticsPublication.php | 43 +- app/Actions/Media/SyncOwnedMedia.php | 40 +- app/Actions/Post/AppendPostMedia.php | 9 +- .../Post/BakeLegacyAspectRatioCrops.php | 282 ------- app/Actions/Post/BuildCalendarPageProps.php | 7 +- app/Actions/Post/BuildPublishPageProps.php | 32 +- app/Actions/Post/CreateChannelPost.php | 12 +- app/Actions/Post/DeleteChannelPosts.php | 140 +--- app/Actions/Post/DeletePost.php | 12 +- app/Actions/Post/DuplicatePost.php | 33 +- app/Actions/Post/FinalizePostPublication.php | 21 +- app/Actions/Post/HostInlineMedia.php | 58 +- app/Actions/Post/ImportExternalPosts.php | 75 +- app/Actions/Post/PruneExpiredPostHistory.php | 2 +- app/Actions/Post/Queue/BuildQueueTimeline.php | 5 +- app/Actions/Post/Queue/ListTakenSlots.php | 22 +- .../Post/Queue/MoveChannelPostToQueueSlot.php | 4 - app/Actions/Post/Queue/ReflowChannelQueue.php | 2 +- .../Post/Queue/ReorderChannelQueue.php | 10 +- app/Actions/Post/RecoverEmptyDraft.php | 2 +- app/Actions/Post/ScheduleNextOccurrence.php | 7 +- app/Actions/Post/UpdatePost.php | 158 +--- app/Actions/Repurpose/ListRepurposeItems.php | 2 +- .../SocialAccount/CountPostsSentThisWeek.php | 7 +- .../SocialAccount/ListInstagramGridPosts.php | 12 +- .../SocialAccount/StoreTelegramReactions.php | 8 +- app/Actions/Workspace/PurgeWorkspace.php | 9 +- .../Commands/CheckUpcomingPostConnections.php | 22 +- .../Commands/ProcessScheduledPosts.php | 21 +- .../Commands/ReconcileGoogleBusinessPosts.php | 15 +- app/Console/Commands/RecoverStuckPosts.php | 132 ++-- app/Console/Commands/RetryFailedPost.php | 136 ++-- .../Commands/Scripts/AuditLegacyPosts.php | 53 -- .../Scripts/BackfillExistingAnalytics.php | 139 ---- .../Scripts/BakeAspectRatioCropsCommand.php | 41 - .../Scripts/PurgeOrphanedPostsCommand.php | 33 - .../Commands/Scripts/ReleaseTryPost2.php | 506 ------------- .../Scripts/SplitLegacyActivePosts.php | 221 ------ app/Dto/Analytics/PublicationFilter.php | 4 +- .../Status.php => Post/PublishStatus.php} | 8 +- app/Enums/Post/Status.php | 7 +- app/Enums/Webhook/EventType.php | 2 - ...tatusUpdated.php => PostStatusUpdated.php} | 11 +- .../Api/ChannelQueueController.php | 2 +- .../Api/PostApprovalController.php | 4 +- app/Http/Controllers/Api/PostController.php | 23 +- .../Api/PostRecurrenceController.php | 2 +- .../App/Concerns/RendersPublishPage.php | 8 +- app/Http/Controllers/App/PostController.php | 13 +- .../Controllers/App/PostGroupController.php | 4 +- .../Api/Post/SetPostRecurrenceRequest.php | 10 +- .../Requests/Api/Post/UpdatePostRequest.php | 5 - .../App/Post/UpdatePostRecurrenceRequest.php | 10 +- .../Requests/App/Post/UpdatePostRequest.php | 90 +-- .../Resources/Api/PostMetricsResource.php | 5 +- .../Resources/Api/PostPlatformResource.php | 35 - app/Http/Resources/Api/PostResource.php | 11 +- .../Resources/Api/RepurposeItemResource.php | 10 +- .../SidebarChannelResource.php | 8 +- .../App/InstagramGridTileResource.php | 8 +- app/Http/Resources/App/PostCardResource.php | 8 +- .../Analytics/BackfillTryPostPublications.php | 69 -- .../Analytics/CollectPublicationMetrics.php | 2 +- app/Jobs/Analytics/SyncTryPostPublication.php | 15 +- app/Jobs/Post/ImportExternalPostMedia.php | 8 +- .../PostHog/SyncAccountPublishingActivity.php | 18 +- app/Jobs/PublishPost.php | 8 +- app/Jobs/PublishToSocialPlatform.php | 123 ++- app/Jobs/ReconcileGoogleBusinessPost.php | 58 +- app/Jobs/Repurpose/PollRepurposeSource.php | 10 +- app/Jobs/VerifyUpcomingPostConnections.php | 74 +- app/Mail/PostAtRisk.php | 24 +- app/Mail/PostNoteAdded.php | 6 +- app/Mail/PostPublishFailed.php | 17 +- app/Mail/PostPublished.php | 16 +- app/Mcp/Tools/Post/ApprovePostTool.php | 2 +- .../Tools/Post/AttachMediaFromUploadTool.php | 4 +- app/Mcp/Tools/Post/AttachMediaFromUrlTool.php | 2 +- .../Tools/Post/ClearPostRecurrenceTool.php | 2 +- app/Mcp/Tools/Post/CreatePostTool.php | 16 +- app/Mcp/Tools/Post/CreatePostsTool.php | 2 +- app/Mcp/Tools/Post/DeletePostTool.php | 2 +- app/Mcp/Tools/Post/GetPostMetricsTool.php | 9 +- app/Mcp/Tools/Post/GetPostTool.php | 2 +- app/Mcp/Tools/Post/ListPostsTool.php | 2 +- app/Mcp/Tools/Post/PreviewPostTool.php | 4 +- app/Mcp/Tools/Post/PublishPostTool.php | 6 +- app/Mcp/Tools/Post/RejectPostTool.php | 2 +- app/Mcp/Tools/Post/SetPostRecurrenceTool.php | 12 +- app/Mcp/Tools/Post/UpdatePostTool.php | 6 +- .../SocialAccount/MovePostToSlotTool.php | 2 +- app/Mcp/Tools/Webhook/CreateWebhookTool.php | 2 +- app/Models/AnalyticsPublication.php | 6 +- app/Models/Post.php | 291 +++++++- app/Models/PostPlatform.php | 265 ------- app/Models/SocialAccount.php | 24 +- app/Observers/PostObserver.php | 53 ++ app/Observers/PostPlatformObserver.php | 65 -- app/Providers/AppServiceProvider.php | 2 - app/Rules/ContentTypeCompatibleWithMedia.php | 33 +- app/Rules/ContentTypeMatchesPlatform.php | 19 +- app/Rules/ContentTypeMatchesPostChannel.php | 33 + app/Rules/ContentTypeMatchesPostPlatform.php | 58 -- app/Services/Media/MediaOptimizer.php | 43 -- app/Services/Post/PostPreviewer.php | 99 ++- .../Social/AbstractLinkedInPublisher.php | 34 +- app/Services/Social/BlueskyPublisher.php | 26 +- .../Social/Concerns/HasSocialHttpClient.php | 22 +- .../Social/Concerns/PublishesThreads.php | 16 +- .../Social/Discord/DiscordPublisher.php | 38 +- app/Services/Social/FacebookPublisher.php | 22 +- .../Social/GoogleBusinessPublisher.php | 66 +- app/Services/Social/InstagramPublisher.php | 30 +- app/Services/Social/MastodonPublisher.php | 34 +- app/Services/Social/PinterestPublisher.php | 70 +- .../Social/Telegram/TelegramPublisher.php | 14 +- app/Services/Social/ThreadsPublisher.php | 22 +- app/Services/Social/TikTokAnalytics.php | 38 +- app/Services/Social/TikTokPublisher.php | 74 +- app/Services/Social/XPublisher.php | 26 +- app/Services/Social/YouTubePublisher.php | 26 +- app/Services/WebhookService.php | 44 +- app/Support/Mail/ApprovalEmailPosts.php | 11 +- app/Support/PostCompositionValidator.php | 25 +- app/Support/PostPlatformMetaRules.php | 270 +++---- app/Support/PostStatusRules.php | 6 +- .../Repurpose/DestinationMetaRules.php | 26 +- .../Requests/Post/PostRequestRules.php | 124 +--- .../Social/AbandonGoogleBusinessReview.php | 26 +- .../GoogleBusinessDerivativeCleaner.php | 16 +- app/Support/Social/PublishCheckpoint.php | 2 +- app/Support/Social/ThreadProgress.php | 16 +- .../Social/TikTokPhotoDerivativeCleaner.php | 12 +- database/factories/PostFactory.php | 144 ++++ database/factories/PostPlatformFactory.php | 198 ----- ...re_post_platforms_for_merge_into_posts.php | 116 +++ ...2942_add_target_columns_to_posts_table.php | 99 +++ ...943_backfill_posts_from_post_platforms.php | 210 ++++++ ...10_08_222944_drop_post_platforms_table.php | 30 + routes/app.php | 2 +- tests/Browser/PublishQueueTest.php | 2 +- .../Actions/Post/DuplicatePostTest.php | 2 +- .../Post/FinalizePostPublicationTest.php | 2 +- tests/Feature/Actions/Post/UpdatePostTest.php | 2 +- .../BackfillExistingAnalyticsCommandTest.php | 423 ----------- .../PublicationReconciliationTest.php | 2 +- tests/Feature/Api/RepurposeApiTest.php | 2 +- tests/Feature/AuditLegacyPostsTest.php | 39 - tests/Feature/BakeAspectRatioCropsTest.php | 304 -------- tests/Feature/Channels/ChannelGridTest.php | 2 +- .../Channels/DisconnectChannelPostsTest.php | 2 +- .../CheckUpcomingPostConnectionsTest.php | 2 +- .../ReconcileGoogleBusinessPostsTest.php | 2 +- .../Commands/RecoverStuckPostsTest.php | 2 +- .../Feature/Commands/RetryFailedPostTest.php | 2 +- .../SyncAccountPublishingActivityTest.php | 2 +- .../Jobs/PublishLegacyTargetMediaTest.php | 2 +- tests/Feature/Jobs/PublishLimitRetryTest.php | 2 +- ...ublishToSocialPlatformMediaRecheckTest.php | 2 +- .../Jobs/PublishToSocialPlatformTest.php | 2 +- .../Jobs/ReconcileGoogleBusinessPostTest.php | 2 +- .../Jobs/SocialPublishingPipelineTest.php | 2 +- .../VerifyUpcomingPostConnectionsTest.php | 2 +- .../Mail/PostPublicationPreviewTest.php | 2 +- tests/Feature/Mcp/RepurposeToolTest.php | 2 +- .../RemoveAspectRatioMetaMigrationTest.php | 2 +- .../Observers/PostPlatformObserverTest.php | 2 +- tests/Feature/Parity/FullPostParityTest.php | 2 +- tests/Feature/PostControllerTest.php | 2 +- .../Feature/Posts/ImportExternalPostsTest.php | 2 +- tests/Feature/Posts/PostRecurrenceTest.php | 2 +- tests/Feature/Posts/PublishPageTest.php | 2 +- .../Feature/Posts/PurgeOrphanedPostsTest.php | 87 --- tests/Feature/ReleaseTryPost2Test.php | 699 ------------------ tests/Feature/Repurpose/WebTest.php | 2 +- .../Social/InstagramPublisherTest.php | 2 +- .../Services/Social/TikTokAnalyticsTest.php | 2 +- .../Services/Social/XPublisherTest.php | 2 +- .../SocialAccount/NetworkUniquenessTest.php | 2 +- tests/Feature/SocialControllerTest.php | 2 +- tests/Feature/SplitLegacyActivePostsTest.php | 424 ----------- tests/Unit/Enums/PostPlatform/StatusTest.php | 2 +- tests/Unit/PostPlatformTest.php | 2 +- 188 files changed, 2274 insertions(+), 5975 deletions(-) delete mode 100644 app/Actions/Post/BakeLegacyAspectRatioCrops.php delete mode 100644 app/Console/Commands/Scripts/AuditLegacyPosts.php delete mode 100644 app/Console/Commands/Scripts/BackfillExistingAnalytics.php delete mode 100644 app/Console/Commands/Scripts/BakeAspectRatioCropsCommand.php delete mode 100644 app/Console/Commands/Scripts/PurgeOrphanedPostsCommand.php delete mode 100644 app/Console/Commands/Scripts/ReleaseTryPost2.php delete mode 100644 app/Console/Commands/Scripts/SplitLegacyActivePosts.php rename app/Enums/{PostPlatform/Status.php => Post/PublishStatus.php} (79%) rename app/Events/{PostPlatformStatusUpdated.php => PostStatusUpdated.php} (67%) delete mode 100644 app/Http/Resources/Api/PostPlatformResource.php delete mode 100644 app/Jobs/Analytics/BackfillTryPostPublications.php delete mode 100644 app/Models/PostPlatform.php delete mode 100644 app/Observers/PostPlatformObserver.php create mode 100644 app/Rules/ContentTypeMatchesPostChannel.php delete mode 100644 app/Rules/ContentTypeMatchesPostPlatform.php delete mode 100644 database/factories/PostPlatformFactory.php create mode 100644 database/migrations/2026_10_08_222940_prepare_post_platforms_for_merge_into_posts.php create mode 100644 database/migrations/2026_10_08_222942_add_target_columns_to_posts_table.php create mode 100644 database/migrations/2026_10_08_222943_backfill_posts_from_post_platforms.php create mode 100644 database/migrations/2026_10_08_222944_drop_post_platforms_table.php delete mode 100644 tests/Feature/Analytics/BackfillExistingAnalyticsCommandTest.php delete mode 100644 tests/Feature/AuditLegacyPostsTest.php delete mode 100644 tests/Feature/BakeAspectRatioCropsTest.php delete mode 100644 tests/Feature/Posts/PurgeOrphanedPostsTest.php delete mode 100644 tests/Feature/ReleaseTryPost2Test.php delete mode 100644 tests/Feature/SplitLegacyActivePostsTest.php diff --git a/app/Actions/Analytics/BuildChannelMetricSeries.php b/app/Actions/Analytics/BuildChannelMetricSeries.php index 09303e5870..1388cb5629 100644 --- a/app/Actions/Analytics/BuildChannelMetricSeries.php +++ b/app/Actions/Analytics/BuildChannelMetricSeries.php @@ -10,7 +10,7 @@ use App\Enums\Analytics\MetricKey; use App\Enums\User\WeekStart; use App\Models\AnalyticsAccountDailySnapshot; -use App\Models\PostPlatform; +use App\Models\Post; use App\Models\SocialAccount; use App\Support\Analytics\PeriodBuckets; use Carbon\CarbonImmutable; @@ -139,7 +139,7 @@ private function indexByDate(array $buckets): array private function publications(SocialAccount $channel, string $accountKey, CarbonImmutable $start, CarbonImmutable $end, ?PublicationFilter $filter): iterable { return $this->latestSnapshots->execute($channel->workspace_id, [$accountKey], $start, $end) - ->leftJoin((new PostPlatform)->getTable().' as destination', 'destination.id', '=', 'publication.post_platform_id') + ->leftJoin((new Post)->getTable().' as destination', 'destination.id', '=', 'publication.post_id') ->when($filter !== null, fn (Builder $filtered): Builder => $filter->apply($filtered)) ->select([ 'publication.id', 'publication.provider_published_at', diff --git a/app/Actions/Analytics/BuildPublicationAnalyticsReport.php b/app/Actions/Analytics/BuildPublicationAnalyticsReport.php index 69b07d30ee..b882eb5e7a 100644 --- a/app/Actions/Analytics/BuildPublicationAnalyticsReport.php +++ b/app/Actions/Analytics/BuildPublicationAnalyticsReport.php @@ -9,7 +9,7 @@ use App\Enums\Analytics\MetricAvailability; use App\Enums\Analytics\MetricKey; use App\Enums\User\WeekStart; -use App\Models\PostPlatform; +use App\Models\Post; use App\Models\Workspace; use App\Support\Analytics\MetricComparison; use App\Support\Analytics\PeriodBuckets; @@ -140,11 +140,11 @@ private function publications(Workspace $workspace, CarbonImmutable $start, Carb public function query(Workspace $workspace, CarbonImmutable $start, CarbonImmutable $end, ?array $accountKeys = null, ?PublicationFilter $filter = null): Builder { return $this->latestSnapshots->execute($workspace->id, $accountKeys, $start, $end) - ->leftJoin((new PostPlatform)->getTable().' as destination', 'destination.id', '=', 'publication.post_platform_id') + ->leftJoin((new Post)->getTable().' as destination', 'destination.id', '=', 'publication.post_id') ->when($filter !== null, fn (Builder $filtered): Builder => $filter->apply($filtered)) ->select([ 'publication.id', 'publication.social_account_key', 'publication.social_account_id', - 'publication.post_platform_id', 'destination.post_id', 'publication.platform', 'publication.network', + 'publication.post_id', 'publication.platform', 'publication.network', 'publication.account_display_name', 'publication.account_username', 'publication.account_avatar_url', 'publication.remote_id', 'publication.provider_published_at', 'publication.origin', 'publication.content_type', @@ -296,7 +296,6 @@ private function top(array $rows): array return array_map(function (object $row): array { return [ 'id' => $row->id, - 'post_platform_id' => $row->post_platform_id, 'post_id' => $row->post_id, 'social_account_key' => $row->social_account_key, 'platform' => $row->platform, diff --git a/app/Actions/Analytics/ListChannelPublicationPerformance.php b/app/Actions/Analytics/ListChannelPublicationPerformance.php index 79f72ee4ec..9ff321b1ca 100644 --- a/app/Actions/Analytics/ListChannelPublicationPerformance.php +++ b/app/Actions/Analytics/ListChannelPublicationPerformance.php @@ -8,7 +8,7 @@ use App\Dto\Analytics\PublicationFilter; use App\Enums\Analytics\MetricAvailability; use App\Enums\Analytics\MetricKey; -use App\Models\PostPlatform; +use App\Models\Post; use App\Models\SocialAccount; use App\Support\Analytics\ChannelMetrics; use App\Support\Analytics\EngagementRate; @@ -50,10 +50,10 @@ private function paginate(SocialAccount $channel, DateRange $range, string $sort $paginator = $this->latestSnapshots ->execute($channel->workspace_id, [$accountKey ?? $this->accountKey->for($channel)], $range->startsAt(), $range->endsAt()) - ->leftJoin((new PostPlatform)->getTable().' as destination', 'destination.id', '=', 'publication.post_platform_id') + ->leftJoin((new Post)->getTable().' as destination', 'destination.id', '=', 'publication.post_id') ->when($filter !== null, fn (Builder $filtered): Builder => $filter->apply($filtered)) ->select([ - 'publication.id', 'destination.post_id', 'publication.excerpt', 'publication.preview_metadata', 'publication.permalink', + 'publication.id', 'publication.post_id', 'publication.excerpt', 'publication.preview_metadata', 'publication.permalink', 'publication.provider_published_at', 'publication.content_type', 'metric.reactions_count', 'metric.comments_count', 'metric.views_count', 'metric.shares_count', 'metric.saves_count', 'metric.reach_count', 'metric.engagement_count', 'metric.exposure_count', diff --git a/app/Actions/Analytics/ReadPublicationAnalytics.php b/app/Actions/Analytics/ReadPublicationAnalytics.php index ee49ad8444..d8b85149bd 100644 --- a/app/Actions/Analytics/ReadPublicationAnalytics.php +++ b/app/Actions/Analytics/ReadPublicationAnalytics.php @@ -10,12 +10,11 @@ use App\Enums\Analytics\MetricPrecision; use App\Enums\Analytics\MetricTimeBasis; use App\Enums\Analytics\MetricUnit; -use App\Enums\PostPlatform\Status; +use App\Enums\Post\PublishStatus; use App\Enums\SocialAccount\Platform; use App\Models\AnalyticsPublication; use App\Models\AnalyticsPublicationDailySnapshot; use App\Models\Post; -use App\Models\PostPlatform; use App\Models\Workspace; use App\Support\Analytics\EngagementRate; use Illuminate\Support\Collection; @@ -35,45 +34,42 @@ public function latestForWorkspacePublication(Workspace $workspace, string $publ return $this->latestForPublication($publication); } - /** @return Collection> */ - public function forPost(Post $post): Collection + /** @return array */ + public function forPost(Post $post): array { - $destinations = $post->postPlatforms - ->where('enabled', true) - ->values(); - $details = $this->latestForPost($post, $destinations); - - return $destinations->map(fn (PostPlatform $destination): array => [ - 'post_platform_id' => $destination->id, - 'platform' => $destination->platform->value, - 'status' => $destination->status->value, - 'platform_post_id' => $destination->platform_post_id, - 'platform_url' => $destination->platform_url, - 'metrics' => $this->visibleDetail(data_get($details, $destination->id, $this->unavailable('not_collected'))), - ]); + return [ + 'post_id' => $post->id, + 'platform' => $post->platform?->value, + 'publish_status' => $post->publish_status->value, + 'platform_post_id' => $post->platform_post_id, + 'platform_url' => $post->platform_url, + 'metrics' => $this->metricsFor($post), + ]; } /** @return array */ - public function forPlatform(PostPlatform $postPlatform): array + public function metricsFor(Post $post): array { - return $this->visibleDetail($this->latestForPostPlatform($postPlatform)); + return $this->visibleDetail($this->latestFor($post)); } /** - * @param Collection $destinations + * The latest metrics of each post, keyed by post id. + * + * @param Collection $posts * @return array> */ - public function latestForPost(Post $post, Collection $destinations): array + public function latestForPosts(string $workspaceId, Collection $posts): array { - $eligible = $destinations->filter(fn (PostPlatform $destination): bool => $this->isCollectable($destination)); + $eligible = $posts->filter(fn (Post $post): bool => $this->isCollectable($post)); $publications = $eligible->isEmpty() ? collect() : AnalyticsPublication::query() ->available() - ->where('workspace_id', $post->workspace_id) - ->whereIn('post_platform_id', $eligible->pluck('id')) + ->where('workspace_id', $workspaceId) + ->whereIn('post_id', $eligible->pluck('id')) ->whereIn('platform', Platform::analyticsValues()) ->get() - ->keyBy('post_platform_id'); + ->keyBy('post_id'); $snapshots = collect(); @@ -94,42 +90,42 @@ public function latestForPost(Post $post, Collection $destinations): array ->keyBy('publication_id'); } - return $destinations->mapWithKeys(function (PostPlatform $destination) use ($publications, $snapshots): array { - if ($destination->status !== Status::Published || ! $destination->platform_post_id) { - return [$destination->id => $this->unavailable('not_published')]; + return $posts->mapWithKeys(function (Post $post) use ($publications, $snapshots): array { + if ($post->publish_status !== PublishStatus::Published || ! $post->platform_post_id) { + return [$post->id => $this->unavailable('not_published')]; } - if (! in_array($destination->platform->value, Platform::analyticsValues(), true)) { - return [$destination->id => $this->unavailable('platform_not_supported')]; + if (! in_array($post->platform?->value, Platform::analyticsValues(), true)) { + return [$post->id => $this->unavailable('platform_not_supported')]; } - $publication = $publications->get($destination->id)?->setRelation( + $publication = $publications->get($post->id)?->setRelation( 'socialAccount', - $destination->relationLoaded('socialAccount') ? $destination->socialAccount : null, + $post->relationLoaded('socialAccount') ? $post->socialAccount : null, ); - return [$destination->id => $publication + return [$post->id => $publication ? $this->detail($publication, $snapshots->get($publication->id)) : $this->unavailable('not_collected')]; })->all(); } /** @return array */ - public function latestForPostPlatform(PostPlatform $postPlatform): array + public function latestFor(Post $post): array { - if ($postPlatform->status !== Status::Published || ! $postPlatform->platform_post_id) { + if ($post->publish_status !== PublishStatus::Published || ! $post->platform_post_id) { return $this->unavailable('not_published'); } - if (! in_array($postPlatform->platform->value, Platform::analyticsValues(), true)) { + if (! in_array($post->platform?->value, Platform::analyticsValues(), true)) { return $this->unavailable('platform_not_supported'); } $publication = AnalyticsPublication::query() ->with('socialAccount') ->available() - ->where('workspace_id', $postPlatform->post->workspace_id) - ->where('post_platform_id', $postPlatform->id) + ->where('workspace_id', $post->workspace_id) + ->where('post_id', $post->id) ->whereIn('platform', Platform::analyticsValues()) ->first(); @@ -148,11 +144,11 @@ public function latestForPublication(AnalyticsPublication $publication): array return $this->detail($publication, $snapshot); } - private function isCollectable(PostPlatform $destination): bool + private function isCollectable(Post $post): bool { - return $destination->status === Status::Published - && filled($destination->platform_post_id) - && in_array($destination->platform->value, Platform::analyticsValues(), true); + return $post->publish_status === PublishStatus::Published + && filled($post->platform_post_id) + && in_array($post->platform?->value, Platform::analyticsValues(), true); } /** @@ -178,7 +174,7 @@ private function detail(AnalyticsPublication $publication, ?AnalyticsPublication 'reason' => null, 'publication' => [ 'id' => $publication->id, - 'post_platform_id' => $publication->post_platform_id, + 'post_id' => $publication->post_id, 'social_account_key' => $publication->social_account_key, 'platform' => $publication->platform->value, 'origin' => $publication->origin->value, diff --git a/app/Actions/Analytics/SyncTryPostPublication.php b/app/Actions/Analytics/SyncTryPostPublication.php index f219eb2929..d8d6b6aa90 100644 --- a/app/Actions/Analytics/SyncTryPostPublication.php +++ b/app/Actions/Analytics/SyncTryPostPublication.php @@ -8,7 +8,7 @@ use App\Enums\Analytics\PublicationContentType; use App\Enums\PostPlatform\ContentType; use App\Models\AnalyticsPublication; -use App\Models\PostPlatform; +use App\Models\Post; use App\Models\SocialAccount; use App\Services\Social\ContentSanitizer; use Illuminate\Support\Str; @@ -21,10 +21,10 @@ public function __construct( private readonly UpsertAnalyticsPublication $publications, ) {} - public function handle(PostPlatform $postPlatform): AnalyticsPublication + public function handle(Post $post): AnalyticsPublication { - $postPlatform->loadMissing(['post', 'socialAccount']); - $account = $postPlatform->socialAccount; + $post->loadMissing('socialAccount'); + $account = $post->socialAccount; if (! $account) { throw new LogicException('A live social account is required to capture publication identity.'); @@ -32,28 +32,28 @@ public function handle(PostPlatform $postPlatform): AnalyticsPublication return $this->fromIdentity( TryPostPublicationIdentity::fromAccount($account, $this->accountKeys->for($account)), - $postPlatform, + $post, $account, ); } public function fromIdentity( TryPostPublicationIdentity $identity, - PostPlatform $postPlatform, + Post $post, ?SocialAccount $liveAccount = null, ): AnalyticsPublication { return $this->publications->tryPost( $identity, - $postPlatform, - $this->normalizedContentType($postPlatform), - $this->excerpt($postPlatform), + $post, + $this->normalizedContentType($post), + $this->excerpt($post), $liveAccount, ); } - private function normalizedContentType(PostPlatform $postPlatform): PublicationContentType + private function normalizedContentType(Post $post): PublicationContentType { - $specializedType = match ($postPlatform->content_type) { + $specializedType = match ($post->content_type) { ContentType::InstagramReel, ContentType::FacebookReel => PublicationContentType::Reel, ContentType::InstagramStory, ContentType::FacebookStory => PublicationContentType::Story, ContentType::YouTubeShort => PublicationContentType::Short, @@ -66,7 +66,7 @@ private function normalizedContentType(PostPlatform $postPlatform): PublicationC return $specializedType; } - $media = $postPlatform->post?->media_items; + $media = $post->media_items; if (! $media || $media->isEmpty()) { return PublicationContentType::Text; @@ -81,9 +81,9 @@ private function normalizedContentType(PostPlatform $postPlatform): PublicationC : PublicationContentType::Image; } - private function excerpt(PostPlatform $postPlatform): ?string + private function excerpt(Post $post): ?string { - $content = app(ContentSanitizer::class)->plainText((string) $postPlatform->post?->content); + $content = app(ContentSanitizer::class)->plainText((string) $post->content); return $content === '' ? null : Str::limit($content, 500); } diff --git a/app/Actions/Analytics/UpsertAnalyticsPublication.php b/app/Actions/Analytics/UpsertAnalyticsPublication.php index e424bee2ec..f9a6ec6a88 100644 --- a/app/Actions/Analytics/UpsertAnalyticsPublication.php +++ b/app/Actions/Analytics/UpsertAnalyticsPublication.php @@ -13,10 +13,8 @@ use App\Models\AnalyticsPublication; use App\Models\AnalyticsPublicationDailySnapshot; use App\Models\Post; -use App\Models\PostPlatform; use App\Models\SocialAccount; use DateTimeInterface; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Support\Facades\DB; use LogicException; @@ -55,23 +53,23 @@ public function external( public function tryPost( TryPostPublicationIdentity $identity, - PostPlatform $postPlatform, + Post $post, PublicationContentType $contentType, ?string $excerpt, ?SocialAccount $liveAccount = null, ): AnalyticsPublication { return $this->persist( identity: $identity, - providerPostId: (string) $postPlatform->platform_post_id, - providerPublishedAt: ($postPlatform->published_at ?? $postPlatform->updated_at)->toImmutable(), + providerPostId: (string) $post->platform_post_id, + providerPublishedAt: ($post->published_at ?? $post->publication_updated_at ?? $post->updated_at)->toImmutable(), origin: PublicationOrigin::TryPost, contentType: $contentType, - providerContentType: $postPlatform->content_type->value, - permalink: $postPlatform->platform_url, + providerContentType: $post->content_type->value, + permalink: $post->platform_url, excerpt: $excerpt, previewMetadata: null, providerMetadata: null, - postPlatformId: $postPlatform->id, + postId: $post->id, liveAccount: $liveAccount, ); } @@ -81,7 +79,7 @@ public function reconcileRemoteId(AnalyticsPublication $publication, string $rem DB::transaction(function () use ($publication, $remoteId): void { $current = AnalyticsPublication::query()->lockForUpdate()->findOrFail($publication->id); - if ($current->remote_id === $remoteId || ! $current->post_platform_id) { + if ($current->remote_id === $remoteId || ! $current->post_id) { return; } @@ -94,8 +92,8 @@ public function reconcileRemoteId(AnalyticsPublication $publication, string $rem ->first(); if ($discovered) { - if ($discovered->post_platform_id && $discovered->post_platform_id !== $current->post_platform_id) { - $this->deleteImportedPost($discovered->post_platform_id); + if ($discovered->post_id && $discovered->post_id !== $current->post_id) { + $this->deleteImportedPost($discovered->post_id); } $discovered->dailySnapshots()->lockForUpdate()->reorder()->lazyById(100)->each(function (AnalyticsPublicationDailySnapshot $snapshot) use ($current): void { @@ -137,20 +135,21 @@ public function reconcileRemoteId(AnalyticsPublication $publication, string $rem $current->remote_id = $remoteId; $current->save(); - PostPlatform::query()->whereKey($current->post_platform_id)->update([ + Post::query()->whereKey($current->post_id)->toBase()->update([ 'platform_post_id' => $remoteId, 'platform_url' => $current->permalink, + 'publication_updated_at' => now(), ]); $publication->setRawAttributes($current->getAttributes(), true); }); } - private function deleteImportedPost(string $postPlatformId): void + private function deleteImportedPost(string $postId): void { $post = Post::query() ->imported() - ->whereHas('postPlatforms', fn (Builder $query): Builder => $query->whereKey($postPlatformId)) + ->whereKey($postId) ->lockForUpdate() ->first(); @@ -177,7 +176,7 @@ private function persist( ?string $excerpt, ?array $previewMetadata, ?array $providerMetadata, - ?string $postPlatformId = null, + ?string $postId = null, ?DateTimeInterface $providerSyncedAt = null, ?SocialAccount $liveAccount = null, ): AnalyticsPublication { @@ -195,7 +194,7 @@ private function persist( $excerpt, $previewMetadata, $providerMetadata, - $postPlatformId, + $postId, $providerSyncedAt, $liveAccount, true, @@ -212,7 +211,7 @@ private function persist( $excerpt, $previewMetadata, $providerMetadata, - $postPlatformId, + $postId, $providerSyncedAt, $liveAccount, false, @@ -235,12 +234,12 @@ private function write( ?string $excerpt, ?array $previewMetadata, ?array $providerMetadata, - ?string $postPlatformId, + ?string $postId, ?DateTimeInterface $providerSyncedAt, ?SocialAccount $liveAccount, bool $mayCreate, ): AnalyticsPublication { - return DB::transaction(function () use ($contentType, $excerpt, $identity, $liveAccount, $mayCreate, $origin, $permalink, $postPlatformId, $previewMetadata, $providerContentType, $providerMetadata, $providerPostId, $providerPublishedAt, $providerSyncedAt): AnalyticsPublication { + return DB::transaction(function () use ($contentType, $excerpt, $identity, $liveAccount, $mayCreate, $origin, $permalink, $postId, $previewMetadata, $providerContentType, $providerMetadata, $providerPostId, $providerPublishedAt, $providerSyncedAt): AnalyticsPublication { $identityFields = [ 'workspace_id' => $identity->workspaceId, 'social_account_key' => $identity->socialAccountKey, @@ -252,9 +251,9 @@ private function write( ->lockForUpdate() ->first(); - if (! $publication && $postPlatformId) { + if (! $publication && $postId) { $publication = AnalyticsPublication::query() - ->where('post_platform_id', $postPlatformId) + ->where('post_id', $postId) ->lockForUpdate() ->first(); } @@ -275,7 +274,7 @@ private function write( $values = [ 'social_account_id' => $liveAccount?->id ?? SocialAccount::query()->whereKey($identity->socialAccountId)->value('id'), - 'post_platform_id' => $postPlatformId ?? $publication->post_platform_id, + 'post_id' => $postId ?? $publication->post_id, 'platform_user_id' => $identity->platformUserId, 'platform' => $identity->platform, 'provider_published_at' => $providerPublishedAt, diff --git a/app/Actions/Media/SyncOwnedMedia.php b/app/Actions/Media/SyncOwnedMedia.php index 4b190b5b51..1976343b11 100644 --- a/app/Actions/Media/SyncOwnedMedia.php +++ b/app/Actions/Media/SyncOwnedMedia.php @@ -8,11 +8,9 @@ use App\Models\Idea; use App\Models\Media; use App\Models\Post; -use App\Models\PostPlatform; use App\Support\Media\MediaCopyBatch; use App\Support\ThreadReplies; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Support\Collection; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; @@ -51,9 +49,9 @@ class SyncOwnedMedia * owner (duplicate, recovery) that may pass through without a row, like * the owner's own stored items do (spec 7.2: missing files are left as is). * - * A post's thread replies (`meta.thread_replies` of its targets) own media - * too: their items are synced the same way and written back to the reply, - * and their rows are kept, so write the target's meta before calling this. + * A post's thread replies (`meta.thread_replies`) own media too: their + * items are synced the same way and written back to the reply, and their + * rows are kept, so save the post's meta before calling this. * * @param list> $items * @param list> $legacyItems @@ -66,8 +64,8 @@ public static function execute(Post|Idea $owner, array $items, MediaCopyBatch $b $workspace = $owner->workspace; $items = self::withoutRepeats(array_values($items)); - $threads = $owner instanceof Post ? self::threadsOf($owner) : collect(); - $replyItems = $threads->flatMap(fn (array $replies): array => array_merge([], ...array_column($replies, 'media')))->all(); + $replies = $locked instanceof Post ? ThreadReplies::of($locked->meta) : []; + $replyItems = array_merge([], ...array_column($replies, 'media')); $ids = self::column([...$items, ...$replyItems], 'id'); $uploadTokens = self::column([...$items, ...$replyItems], 'upload_token'); self::lockRows($workspace->id, $ownerColumn, $owner->getKey(), $ids, $uploadTokens); @@ -119,34 +117,20 @@ public static function execute(Post|Idea $owner, array $items, MediaCopyBatch $b $kept = self::column($final, 'id'); $replyErrorKey = Str::beforeLast($errorKey, 'media').'meta.thread_replies'; - foreach ($threads as $postPlatformId => $replies) { - foreach ($replies as $index => $reply) { - $replies[$index]['media'] = $sync(self::withoutRepeats($reply['media']), "{$replyErrorKey}.{$index}.media"); - $kept = [...$kept, ...self::column($replies[$index]['media'], 'id')]; - } - - $postPlatform = $owner->postPlatforms()->whereKey($postPlatformId)->firstOrFail(); - $postPlatform->forceFill(['meta' => [...($postPlatform->meta ?? []), 'thread_replies' => $replies]])->save(); + foreach ($replies as $index => $reply) { + $replies[$index]['media'] = $sync(self::withoutRepeats($reply['media']), "{$replyErrorKey}.{$index}.media"); + $kept = [...$kept, ...self::column($replies[$index]['media'], 'id')]; } DeleteOwnedMedia::forRows($owner->ownedMedia()->whereNotIn('id', $kept)->pluck('id')->all()); - $owner->forceFill(['media' => $final])->save(); + $owner->forceFill([ + 'media' => $final, + ...($replies !== [] ? ['meta' => [...($locked->meta ?? []), 'thread_replies' => $replies]] : []), + ])->save(); return $final; } - /** - * The thread replies of each of the post's targets that has any. - * - * @return Collection>}>> - */ - private static function threadsOf(Post $post): Collection - { - return $post->postPlatforms()->get() - ->mapWithKeys(fn (PostPlatform $postPlatform): array => [$postPlatform->id => ThreadReplies::of($postPlatform->meta)]) - ->filter(fn (array $replies): bool => $replies !== []); - } - /** * @param list $ids * @param list $uploadTokens diff --git a/app/Actions/Post/AppendPostMedia.php b/app/Actions/Post/AppendPostMedia.php index 834df2c123..2b1b4f020a 100644 --- a/app/Actions/Post/AppendPostMedia.php +++ b/app/Actions/Post/AppendPostMedia.php @@ -7,7 +7,6 @@ use App\Enums\Post\Status as PostStatus; use App\Enums\PostPlatform\ContentType; use App\Models\Post; -use App\Models\PostPlatform; use App\Models\User; use App\Support\Media\MediaCopyBatch; use App\Support\PostApproval; @@ -37,11 +36,9 @@ public static function execute(Post $post, array $items, ?User $actor): void throw ValidationException::withMessages(['post' => PostStatusRules::editBlockedMessage()]); } - $post->postPlatforms()->enabled()->get() - ->filter(fn (PostPlatform $target): bool => ContentType::derivesFromMedia($target->platform)) - ->each(fn (PostPlatform $target): bool => $target->update([ - 'content_type' => ContentType::forMedia($target->platform, $post->media ?? []), - ])); + if ($post->hasDestination() && ContentType::derivesFromMedia($post->platform)) { + $post->update(['content_type' => ContentType::forMedia($post->platform, $post->media ?? [])]); + } if ($post->status === PostStatus::Scheduled && PostApproval::isRequired($post->workspace, $actor, $post->status->value)) { UpdatePost::execute($post->workspace, $post, ['status' => $post->status->value], $actor); diff --git a/app/Actions/Post/BakeLegacyAspectRatioCrops.php b/app/Actions/Post/BakeLegacyAspectRatioCrops.php deleted file mode 100644 index 35e765fa58..0000000000 --- a/app/Actions/Post/BakeLegacyAspectRatioCrops.php +++ /dev/null @@ -1,282 +0,0 @@ - - */ - private const array RATIOS = ['1:1' => 1.0, '4:5' => 4 / 5, '16:9' => 16 / 9]; - - private const float FALLBACK_RATIO = 1.0; - - private const float RATIO_TOLERANCE = 0.001; - - private const int CHUNK = 100; - - /** - * @return Builder - */ - public static function candidates(): Builder - { - return PostPlatform::query() - ->enabled() - ->whereIn('platform', [Platform::Facebook, Platform::Instagram, Platform::InstagramFacebook]) - ->whereIn('content_type', [ContentType::InstagramFeed, ContentType::FacebookPost]) - ->whereNotNull('meta->'.self::META_KEY) - ->where('meta->'.self::META_KEY, '!=', self::ORIGINAL) - ->whereIn('status', [Status::Pending, Status::Publishing, Status::Retrying]) - ->whereHas('post', fn (Builder $post): Builder => $post->whereIn('status', [PostStatus::Scheduled, PostStatus::PendingApproval, PostStatus::Publishing])); - } - - public static function pending(): int - { - return self::candidates()->distinct()->count('post_id'); - } - - /** - * @return array{baked_posts: int, baked_images: int, skipped_posts: list, unreadable_images: list, failed_posts: list, stripped_targets: int} - */ - public static function execute(): array - { - $result = ['baked_posts' => 0, 'baked_images' => 0, 'skipped_posts' => [], 'unreadable_images' => [], 'failed_posts' => [], 'stripped_targets' => 0]; - - Post::query() - ->whereIn('id', self::candidates()->select('post_id')) - ->select('id') - ->orderBy('id') - ->chunkById(self::CHUNK, function ($posts) use (&$result): void { - foreach ($posts as $candidate) { - try { - $outcome = MediaCopyBatch::run(fn (MediaCopyBatch $batch): array => self::bakePost($candidate->id, $batch)); - } catch (Throwable $exception) { - report($exception); - $result['failed_posts'][] = $candidate->id; - - continue; - } - - if (data_get($outcome, 'skipped')) { - $result['skipped_posts'][] = $candidate->id; - } - - $baked = (int) data_get($outcome, 'baked', 0); - $result['baked_images'] += $baked; - $result['baked_posts'] += $baked > 0 ? 1 : 0; - $result['unreadable_images'] = [...$result['unreadable_images'], ...data_get($outcome, 'unreadable', [])]; - } - }); - - $result['stripped_targets'] = self::stripRemaining([...$result['failed_posts'], ...$result['skipped_posts']]); - - return $result; - } - - /** - * @return array{baked: int, skipped: bool, unreadable: list} - */ - private static function bakePost(string $postId, MediaCopyBatch $batch): array - { - $outcome = ['baked' => 0, 'skipped' => false, 'unreadable' => []]; - $post = Post::query()->lockForUpdate()->find($postId); - - if ($post === null) { - return $outcome; - } - - $targets = $post->postPlatforms()->enabled()->orderBy('id')->lockForUpdate()->get(); - $target = self::candidates()->where('post_id', $post->id)->first(); - - if ($target === null) { - return $outcome; - } - - if ($targets->count() > 1) { - return [...$outcome, 'skipped' => true]; - } - - if (PublishCheckpoint::instagramWorkflow($target->error_context) === null) { - $ratio = self::RATIOS[(string) data_get($target->meta, self::META_KEY)] ?? self::FALLBACK_RATIO; - $outcome = self::bakeMedia($post, $target, $ratio, $batch); - } - - self::strip($target->id, $target->meta); - - return $outcome; - } - - /** - * @return array{baked: int, skipped: bool, unreadable: list} - */ - private static function bakeMedia(Post $post, PostPlatform $target, float $ratio, MediaCopyBatch $batch): array - { - $outcome = ['baked' => 0, 'skipped' => false, 'unreadable' => []]; - $items = array_values($post->media ?? []); - $first = data_get($items, 0); - $cropsImages = match ($target->content_type) { - ContentType::InstagramFeed => true, - ContentType::FacebookPost => is_array($first) && MediaItem::fromArray($first)->isImage(), - default => false, - }; - - if (! $cropsImages) { - return $outcome; - } - - $baked = []; - - foreach ($items as $item) { - if (! is_array($item) || ! MediaItem::fromArray($item)->isImage()) { - $baked[] = $item; - - continue; - } - - $bytes = rescue(fn (): ?string => Storage::get((string) data_get($item, 'path')), null, report: false); - $dimensions = is_string($bytes) ? ImageDimensions::fromBytes($bytes) : null; - - if ($dimensions === null) { - $outcome['unreadable'][] = "{$post->id}:".data_get($item, 'id'); - $baked[] = $item; - - continue; - } - - if (abs(data_get($dimensions, 'width') / data_get($dimensions, 'height') - $ratio) < self::RATIO_TOLERANCE) { - $baked[] = $item; - - continue; - } - - $upload = self::croppedUpload($post, $item, $bytes, $ratio, $batch); - $baked[] = [ - ...array_intersect_key($item, array_flip(['meta', 'source', 'source_meta'])), - 'id' => $upload->id, - 'upload_token' => $upload->upload_token, - ]; - $outcome['baked']++; - } - - if ($outcome['baked'] > 0) { - $updatedAt = $post->getRawOriginal('updated_at'); - SyncOwnedMedia::execute($post, $baked, $batch); - Post::query()->whereKey($post->id)->toBase()->update(['updated_at' => $updatedAt]); - } - - return $outcome; - } - - /** - * @param array $item - */ - private static function croppedUpload(Post $post, array $item, string $bytes, float $ratio, MediaCopyBatch $batch): Media - { - $input = tempnam(sys_get_temp_dir(), 'bake_in_'); - - try { - file_put_contents($input, $bytes); - $cropped = app(MediaOptimizer::class)->cropToAspectRatio($input, $ratio); - - try { - $name = pathinfo((string) (data_get($item, 'original_filename') ?: data_get($item, 'path')), PATHINFO_FILENAME); - $upload = $post->workspace->addMediaFromPath($cropped, "{$name}.jpg", Media::COLLECTION_UPLOADS, mimeType: 'image/jpeg'); - $batch->rememberCopy($upload->path); - $upload->issueUploadToken(); - - return $upload; - } finally { - @unlink($cropped); - } - } finally { - @unlink($input); - } - } - - /** - * @param array|null $meta - */ - private static function strip(string $postPlatformId, ?array $meta): void - { - $meta = (array) $meta; - unset($meta[self::META_KEY]); - - PostPlatform::query()->whereKey($postPlatformId)->toBase()->update(['meta' => json_encode($meta)]); - } - - /** - * @param list $keptPostIds - */ - private static function stripRemaining(array $keptPostIds): int - { - $stripped = 0; - - DB::table('post_platforms') - ->whereNotNull('meta->'.self::META_KEY) - ->when($keptPostIds !== [], fn ($query) => $query->whereNotIn('post_id', $keptPostIds)) - ->select(['id', 'meta']) - ->chunkById(500, function ($rows) use (&$stripped): void { - foreach ($rows as $row) { - $meta = json_decode((string) $row->meta, true); - - if (! is_array($meta)) { - continue; - } - - self::strip($row->id, $meta); - $stripped++; - } - }); - - return $stripped; - } -} diff --git a/app/Actions/Post/BuildCalendarPageProps.php b/app/Actions/Post/BuildCalendarPageProps.php index 9154d3d2ea..707235cfa4 100644 --- a/app/Actions/Post/BuildCalendarPageProps.php +++ b/app/Actions/Post/BuildCalendarPageProps.php @@ -65,11 +65,8 @@ public static function handle(Request $request, Workspace $workspace, ?SocialAcc return $workspaceAccounts ??= $channel ? collect() : $workspace->socialAccounts()->get(); }; - $scopedPosts = fn (): Builder => BuildPublishPageProps::cardQuery($workspace->posts(), $scopedChannelIds, $labelIds, $untagged) - ->when($scopedChannelIds !== null, fn (Builder $query) => $query->whereHas( - 'postPlatforms', - fn (Builder $platforms) => $platforms->enabled()->whereIn('social_account_id', $scopedChannelIds), - )) + $scopedPosts = fn (): Builder => BuildPublishPageProps::cardQuery($workspace->posts(), $labelIds, $untagged) + ->onChannels($scopedChannelIds) ->visiblePendingApprovalsFor(BuildPublishPageProps::pendingApprovalsRequester($request->user(), $workspace)); $range = [$rangeStart->utc(), $rangeEnd->utc()]; diff --git a/app/Actions/Post/BuildPublishPageProps.php b/app/Actions/Post/BuildPublishPageProps.php index b9763fb03b..4960b8fccf 100644 --- a/app/Actions/Post/BuildPublishPageProps.php +++ b/app/Actions/Post/BuildPublishPageProps.php @@ -47,7 +47,6 @@ class BuildPublishPageProps public const SENT_STATUSES = [ PostStatus::Published, - PostStatus::PartiallyPublished, PostStatus::Failed, ]; @@ -93,7 +92,7 @@ public static function handle(Request $request, Workspace $workspace, ?SocialAcc $tab = $resolveTab($request->query('tab'), $focusedPostId); - $cards = fn (): Builder => self::cardQuery(clone $basePosts, $scopedChannelIds, $labelIds, $untagged); + $cards = fn (): Builder => self::cardQuery(clone $basePosts, $labelIds, $untagged); $hasDataResult = null; $hasData = function () use (&$hasDataResult, $tab, $workspace, $channel, $requester): bool { @@ -183,10 +182,7 @@ public static function displayTimezone(mixed $requested, string $userTimezone): */ private static function hasData(string $tab, Workspace $workspace, ?SocialAccount $channel, ?User $requester): bool { - $posts = $workspace->posts()->when($channel !== null, fn (Builder $query) => $query->whereHas( - 'postPlatforms', - fn (Builder $platforms) => $platforms->enabled()->where('social_account_id', $channel->id), - )); + $posts = $workspace->posts()->when($channel !== null, fn (Builder $query) => $query->where('posts.social_account_id', $channel->id)); return match ($tab) { self::TAB_QUEUE => $posts->where(fn (Builder $query) => $query @@ -288,16 +284,13 @@ public static function pendingApprovalsRequester(User $user, Workspace $workspac } /** - * @param list|null $channelIds * @param list $labelIds */ - public static function cardQuery(HasMany $basePosts, ?array $channelIds, array $labelIds, bool $untagged): Builder + public static function cardQuery(HasMany $basePosts, array $labelIds, bool $untagged): Builder { return $basePosts->getQuery() ->with([ - 'postPlatforms' => fn ($platforms) => $platforms->enabled() - ->when($channelIds !== null, fn ($platforms) => $platforms->whereIn('social_account_id', $channelIds)) - ->with('socialAccount'), + 'socialAccount', 'user.avatarMedia', 'labels', ]) @@ -411,13 +404,9 @@ public static function decorate(Collection $posts): void $post->unsetRelation('approvalRequestedBy'); $post->setAttribute('can_delete', $deletable[$authorship] && ! PostStatusRules::blocksDeletion($post)); - foreach ($post->postPlatforms as $platform) { - $account = $platform->socialAccount; - - if ($account === null) { - continue; - } + $account = $post->socialAccount; + if ($account !== null) { $hasSchedule[$account->id] ??= $account->hasPostingSchedule(); $account->setAttribute('has_posting_schedule', $hasSchedule[$account->id]); } @@ -436,15 +425,10 @@ public static function attachMetrics(Collection $posts): void return; } - $metrics = app(ReadPublicationAnalytics::class)->latestForPost( - $first, - $posts->flatMap(fn (Post $post) => $post->postPlatforms)->values(), - ); + $metrics = app(ReadPublicationAnalytics::class)->latestForPosts($first->workspace_id, $posts->values()); foreach ($posts as $post) { - $post->setAttribute('metrics', $post->postPlatforms - ->mapWithKeys(fn ($platform): array => [$platform->id => data_get($metrics, $platform->id)]) - ->all()); + $post->setAttribute('metrics', data_get($metrics, $post->id)); } } } diff --git a/app/Actions/Post/CreateChannelPost.php b/app/Actions/Post/CreateChannelPost.php index 7779b3df48..57f9fbad40 100644 --- a/app/Actions/Post/CreateChannelPost.php +++ b/app/Actions/Post/CreateChannelPost.php @@ -6,10 +6,10 @@ use App\Actions\Media\SyncOwnedMedia; use App\Actions\Post\Queue\ReflowChannelQueue; +use App\Enums\Post\PublishStatus; use App\Enums\Post\QueuePosition; use App\Enums\Post\ScheduleMode; use App\Enums\Post\Status as PostStatus; -use App\Enums\PostPlatform\Status as PostPlatformStatus; use App\Models\Post; use App\Models\SocialAccount; use App\Models\User; @@ -24,7 +24,7 @@ class CreateChannelPost { /** - * Create one post with exactly one publishing target from a resolved destination. + * Create one post for one channel from a resolved destination. * A destination with a `queue` position is enqueued on its channel, so the caller * must hold that channel's queue lock and run this inside a DB transaction. * The destination's media ends owned by the new post (see SyncOwnedMedia). @@ -41,7 +41,7 @@ public static function execute(Workspace $workspace, User $user, array $destinat $position = data_get($destination, 'queue'); $pending = $status === PostStatus::PendingApproval; - $post = $workspace->posts()->create([ + $post = $workspace->posts()->forceCreate([ 'post_group_id' => data_get($destination, 'post_group_id') ?? (string) Str::uuid7(), 'user_id' => $user->id, 'content' => $destination['content'], @@ -58,15 +58,11 @@ public static function execute(Workspace $workspace, User $user, array $destinat ? Carbon::parse($destination['scheduled_at'])->utc() : null, ...($pending ? PostApproval::transition(PostStatus::Draft, PostStatus::PendingApproval, $user, $position instanceof QueuePosition ? $position : null) : []), - ]); - - $post->postPlatforms()->create([ 'social_account_id' => $account->id, 'platform' => $account->platform, ...$account->channelSnapshot(), 'content_type' => $destination['content_type'], - 'status' => PostPlatformStatus::Pending, - 'enabled' => true, + 'publish_status' => PublishStatus::Pending, 'meta' => PostPlatformMetaRules::normalize($destination['meta'] ?? []), ]); diff --git a/app/Actions/Post/DeleteChannelPosts.php b/app/Actions/Post/DeleteChannelPosts.php index 631bce7264..feb8c17fe8 100644 --- a/app/Actions/Post/DeleteChannelPosts.php +++ b/app/Actions/Post/DeleteChannelPosts.php @@ -5,158 +5,52 @@ namespace App\Actions\Post; use App\Actions\Media\DeleteOwnedMedia; -use App\Enums\Post\Status as PostStatus; use App\Enums\SocialAccount\Platform; use App\Models\Post; -use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Support\Social\GoogleBusinessDerivativeCleaner; -use Closure; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\DB; /** * Deletes every post of a channel that is going away, whatever its status or - * origin, quietly: no post.deleted webhook, notification or observer. A legacy - * post that still publishes through another channel only loses this channel's - * target. Analytics publications stay and are unlinked by the FK, so the same + * origin, quietly: no post.deleted webhook, notification or observer. + * Analytics publications stay and are unlinked by the FK, so the same * identity connected again imports its posts afresh. */ class DeleteChannelPosts { - /** - * @return array{deleted_posts: int, detached_targets: int} - */ - public static function forAccount(SocialAccount $account): array - { - return self::purge( - fn (Builder $targets): Builder => $targets->where('social_account_id', $account->id), - fn (Builder $live): Builder => $live->where('social_account_id', '!=', $account->id), - $account->workspace_id, - ); - } - - /** - * Targets left without an account by a disconnect from before channels - * took their posts with them. - * - * @return array{deleted_posts: int, detached_targets: int} - */ - public static function orphaned(?string $workspaceId = null): array - { - return self::purge(self::orphanedTargets(...), self::anyChannel(...), $workspaceId); - } - - /** - * What `orphaned()` would delete and detach, without changing anything. - * - * @return array{deleted_posts: int, detached_targets: int} - */ - public static function orphanedPlan(?string $workspaceId = null): array + public static function forAccount(SocialAccount $account): int { - [$posts, $liveElsewhere] = self::scopes(self::orphanedTargets(...), self::anyChannel(...), $workspaceId); - - return [ - 'deleted_posts' => $posts()->whereDoesntHave('postPlatforms', $liveElsewhere)->count(), - 'detached_targets' => self::orphanedTargets(PostPlatform::query()) - ->whereIn('post_id', $posts()->whereHas('postPlatforms', $liveElsewhere)->select('id')) - ->count(), - ]; - } - - /** - * @param Builder $targets - * @return Builder - */ - private static function orphanedTargets(Builder $targets): Builder - { - return $targets->whereNull('social_account_id'); - } - - /** - * @param Builder $targets - * @return Builder - */ - private static function anyChannel(Builder $targets): Builder - { - return $targets; - } - - /** - * The posts that have a target on the channel, and the scope of their - * targets that still publish elsewhere. - * - * @param Closure(Builder): Builder $channelTargets - * @param Closure(Builder): Builder $otherChannels - * @return array{0: Closure(): Builder, 1: Closure(Builder): Builder} - */ - private static function scopes(Closure $channelTargets, Closure $otherChannels, ?string $workspaceId): array - { - return [ - fn (): Builder => Post::query() - ->when($workspaceId !== null, fn (Builder $query): Builder => $query->where('workspace_id', $workspaceId)) - ->whereHas('postPlatforms', $channelTargets), - fn (Builder $targets): Builder => $otherChannels($targets->enabled()->whereNotNull('social_account_id')), - ]; - } - - /** - * @param Closure(Builder): Builder $channelTargets - * @param Closure(Builder): Builder $otherChannels - * @return array{deleted_posts: int, detached_targets: int} - */ - private static function purge(Closure $channelTargets, Closure $otherChannels, ?string $workspaceId): array - { - [$posts, $liveElsewhere] = self::scopes($channelTargets, $otherChannels, $workspaceId); - $deletedPosts = 0; - $posts() - ->whereDoesntHave('postPlatforms', $liveElsewhere) - ->select('id') - ->chunkById(PruneExpiredPostHistory::CHUNK, function (Collection $chunk) use (&$deletedPosts): void { - $ids = $chunk->modelKeys(); - - self::pruneGoogleBusinessImages(PostPlatform::query()->whereIn('post_id', $ids)); + Post::query() + ->where('social_account_id', $account->id) + ->select(['id', 'platform', 'legacy_target_id']) + ->chunkById(PruneExpiredPostHistory::CHUNK, function (Collection $posts) use (&$deletedPosts): void { + self::pruneGoogleBusinessImages($posts); - DB::transaction(function () use ($ids): void { - DeleteOwnedMedia::forPosts($ids); - Post::query()->whereKey($ids)->delete(); + DB::transaction(function () use ($posts): void { + DeleteOwnedMedia::forPosts($posts->modelKeys()); + Post::query()->whereKey($posts->modelKeys())->delete(); }); - $deletedPosts += count($ids); - }); - - $detachedTargets = 0; - - $posts() - ->whereHas('postPlatforms', $liveElsewhere) - ->select(['id', 'status']) - ->chunkById(PruneExpiredPostHistory::CHUNK, function (Collection $chunk) use ($channelTargets, &$detachedTargets): void { - $targets = $channelTargets(PostPlatform::query()->whereIn('post_id', $chunk->modelKeys())); - - self::pruneGoogleBusinessImages(clone $targets); - $detachedTargets += $targets->delete(); - - $chunk - ->filter(fn (Post $post): bool => $post->status === PostStatus::Publishing) - ->each(fn (Post $post) => app(FinalizePostPublication::class)->handle($post)); + $deletedPosts += $posts->count(); }); - return ['deleted_posts' => $deletedPosts, 'detached_targets' => $detachedTargets]; + return $deletedPosts; } /** * Deletes the images once the caller's transaction commits, so a rolled * back disconnect keeps them. * - * @param Builder $targets + * @param Collection $posts */ - private static function pruneGoogleBusinessImages(Builder $targets): void + private static function pruneGoogleBusinessImages(Collection $posts): void { - $ids = $targets->where('platform', Platform::GoogleBusiness)->pluck('id'); + $googleBusiness = $posts->filter(fn (Post $post): bool => $post->platform === Platform::GoogleBusiness)->values(); - DB::afterCommit(fn () => $ids->each(fn (string $id) => app(GoogleBusinessDerivativeCleaner::class)->cleanup($id))); + DB::afterCommit(fn () => $googleBusiness->each(fn (Post $post) => app(GoogleBusinessDerivativeCleaner::class)->cleanup($post))); } } diff --git a/app/Actions/Post/DeletePost.php b/app/Actions/Post/DeletePost.php index b1f0580154..8603827d14 100644 --- a/app/Actions/Post/DeletePost.php +++ b/app/Actions/Post/DeletePost.php @@ -37,20 +37,16 @@ public static function execute(Post $post, bool $respectStatus = false): void throw ValidationException::withMessages(['post' => __('posts.flash.cannot_delete_published')]); } - $googleBusinessIds = $post->postPlatforms() - ->where('platform', Platform::GoogleBusiness) - ->pluck('id'); - AnalyticsPublication::query() - ->whereIn('post_platform_id', $post->postPlatforms()->pluck('id')->all()) + ->where('post_id', $post->id) ->update(['post_dismissed_at' => now()]); DeleteOwnedMedia::forPosts([$postId]); $post->delete(); - DB::afterCommit(fn () => $googleBusinessIds->each( - fn (string $id) => app(GoogleBusinessDerivativeCleaner::class)->cleanup($id), - )); + if ($post->platform === Platform::GoogleBusiness) { + DB::afterCommit(fn () => app(GoogleBusinessDerivativeCleaner::class)->cleanup($post)); + } return true; }); diff --git a/app/Actions/Post/DuplicatePost.php b/app/Actions/Post/DuplicatePost.php index 4acce903e8..19a0a8c99b 100644 --- a/app/Actions/Post/DuplicatePost.php +++ b/app/Actions/Post/DuplicatePost.php @@ -7,7 +7,6 @@ use App\Enums\Post\CreatedVia; use App\Enums\Post\Status as PostStatus; use App\Models\Post; -use App\Models\PostPlatform; use App\Models\User; use App\Support\Media\MediaCopyBatch; use App\Support\PostPlatformMetaRules; @@ -15,29 +14,21 @@ use Illuminate\Validation\ValidationException; /** - * Duplicate one account's version into an independent draft. + * Duplicate a post into an independent draft for the same channel. */ class DuplicatePost { - public static function execute(Post $original, User $user, ?string $targetId = null): Post + public static function execute(Post $original, User $user): Post { - return MediaCopyBatch::run(function (MediaCopyBatch $batch) use ($original, $user, $targetId): Post { - $targets = $original->postPlatforms() - ->enabled() - ->whereHas('socialAccount') - ->get(); - $target = $targetId === null && $targets->count() === 1 - ? $targets->first() - : $targets->firstWhere('id', $targetId); - - if ($target === null) { + return MediaCopyBatch::run(function (MediaCopyBatch $batch) use ($original, $user): Post { + if ($original->socialAccount === null) { throw ValidationException::withMessages([ - 'post_platform_id' => __('validation.exists', ['attribute' => 'post platform']), + 'post' => __('posts.errors.choose_channel'), ]); } return CreateChannelPost::execute($original->workspace, $user, [ - ...self::destination($original, $target), + ...self::destination($original), 'status' => PostStatus::Draft->value, 'created_via' => CreatedVia::Web, ], $batch); @@ -45,12 +36,12 @@ public static function execute(Post $original, User $user, ?string $targetId = n } /** - * The original's content, media, labels and per-platform settings for one target, - * ready for CreateChannelPost. + * The original's content, media, labels, channel and settings, ready for + * CreateChannelPost. * * @return array */ - public static function destination(Post $original, PostPlatform $target): array + public static function destination(Post $original): array { return [ 'content' => $original->content, @@ -59,9 +50,9 @@ public static function destination(Post $original, PostPlatform $target): array 'meta' => data_get($item, 'meta'), ...array_intersect_key($item, array_flip(['source', 'source_meta'])), ], $original->media ?? []), - 'social_account_id' => $target->social_account_id, - 'content_type' => $target->content_type->value, - 'meta' => Arr::except($target->meta ?? [], PostPlatformMetaRules::SYSTEM_KEYS), + 'social_account_id' => $original->social_account_id, + 'content_type' => $original->content_type->value, + 'meta' => Arr::except($original->meta ?? [], PostPlatformMetaRules::SYSTEM_KEYS), 'label_ids' => $original->labels()->pluck('workspace_labels.id')->all(), 'legacy_media' => $original->media ?? [], ]; diff --git a/app/Actions/Post/FinalizePostPublication.php b/app/Actions/Post/FinalizePostPublication.php index fb42cf8d94..1b0175c75c 100644 --- a/app/Actions/Post/FinalizePostPublication.php +++ b/app/Actions/Post/FinalizePostPublication.php @@ -5,17 +5,16 @@ namespace App\Actions\Post; use App\Enums\Notification\Type; +use App\Enums\Post\PublishStatus; use App\Enums\Post\Status as PostStatus; -use App\Enums\PostPlatform\Status as PostPlatformStatus; use App\Jobs\SendNotification; use App\Mail\PostPublished; use App\Mail\PostPublishFailed; use App\Models\Post; -use App\Models\PostPlatform; use Illuminate\Support\Facades\DB; /** - * Settles a post once every enabled target has reached a terminal state, and + * Settles a post once its publication has reached a terminal state, and * notifies the owner once. Shared by PublishToSocialPlatform, PublishPost::failed, * RecoverStuckPosts, and ReconcileGoogleBusinessPost. */ @@ -26,7 +25,7 @@ public function handle(Post $post): void /** @var array{post: Post, successful: bool}|null $outcome */ $outcome = DB::transaction(function () use ($post): ?array { $post = Post::query() - ->with(['workspace.owner', 'postPlatforms.socialAccount']) + ->with(['workspace.owner', 'socialAccount']) ->whereKey($post->id) ->lockForUpdate() ->first(); @@ -35,9 +34,7 @@ public function handle(Post $post): void return null; } - $targets = $post->postPlatforms->where('enabled', true); - - if ($targets->isEmpty()) { + if (! $post->hasDestination()) { if ($post->status !== PostStatus::Publishing) { return null; } @@ -47,20 +44,14 @@ public function handle(Post $post): void return ['post' => $post, 'successful' => false]; } - $finished = $targets->filter(fn (PostPlatform $target): bool => $target->status->isFinished()); - $published = $finished->where('status', PostPlatformStatus::Published); - $failed = $finished->reject(fn (PostPlatform $target): bool => $target->status === PostPlatformStatus::Published); - - if ($finished->count() < $targets->count()) { + if (! $post->publish_status->isFinished()) { return null; } - $successful = $failed->isEmpty(); + $successful = $post->publish_status === PublishStatus::Published; if ($successful) { $post->markAsPublished(); - } elseif ($published->isNotEmpty()) { - $post->markAsPartiallyPublished(); } else { $post->markAsFailed(); } diff --git a/app/Actions/Post/HostInlineMedia.php b/app/Actions/Post/HostInlineMedia.php index 3e9b380e8d..048d987af4 100644 --- a/app/Actions/Post/HostInlineMedia.php +++ b/app/Actions/Post/HostInlineMedia.php @@ -48,13 +48,19 @@ public static function forBatch(Workspace $workspace, array $data): array } } - return self::threadReplies($workspace, $data, 'destinations', $fetched); + foreach (data_get($data, 'destinations', []) as $index => $destination) { + if (is_array(data_get($destination, 'meta'))) { + $data['destinations'][$index]['meta'] = self::threadReplies($workspace, $destination['meta'], "destinations.{$index}.meta", $fetched); + } + } + + return $data; } /** - * Host the media of a single post request and of the thread replies of - * its targets (`platforms.*.meta.thread_replies.*.media`), so a URL is - * accepted wherever a post takes media. + * Host the media of a single post request and of its thread replies + * (`meta.thread_replies.*.media`), so a URL is accepted wherever a post + * takes media. * * @param array $allowedTypes * @param array $data @@ -70,7 +76,11 @@ public static function forPost(Workspace $workspace, array $allowedTypes, array $data['media'] = self::execute($workspace, $allowedTypes, $data['media'], fetched: $fetched); } - return self::threadReplies($workspace, $data, 'platforms', $fetched); + if (is_array(data_get($data, 'meta'))) { + $data['meta'] = self::threadReplies($workspace, $data['meta'], 'meta', $fetched); + } + + return $data; } /** @@ -146,37 +156,29 @@ public static function execute(Workspace $workspace, array $allowedTypes, array } /** - * Host the reply media of every target listed under `$listKey` that - * carries a URL. Replies sent only as ids or upload tokens are left for - * the save to resolve, as before. - * - * @param array $data + * @param array $meta * @param array $fetched * @return array - * - * @throws ValidationException */ - private static function threadReplies(Workspace $workspace, array $data, string $listKey, array &$fetched): array + private static function threadReplies(Workspace $workspace, array $meta, string $errorPrefix, array &$fetched): array { - foreach ((array) data_get($data, $listKey, []) as $target => $entry) { - foreach ((array) data_get($entry, 'meta.thread_replies', []) as $index => $reply) { - $media = (array) data_get($reply, 'media', []); + foreach ((array) data_get($meta, 'thread_replies', []) as $index => $reply) { + $media = (array) data_get($reply, 'media', []); - if (! collect($media)->contains(fn (mixed $item): bool => filled(data_get($item, 'url')))) { - continue; - } - - $data[$listKey][$target]['meta']['thread_replies'][$index]['media'] = self::execute( - $workspace, - MediaType::cases(), - $media, - "{$listKey}.{$target}.meta.thread_replies.{$index}.media", - $fetched, - ); + if (! collect($media)->contains(fn (mixed $item): bool => filled(data_get($item, 'url')))) { + continue; } + + $meta['thread_replies'][$index]['media'] = self::execute( + $workspace, + MediaType::cases(), + $media, + "{$errorPrefix}.thread_replies.{$index}.media", + $fetched, + ); } - return $data; + return $meta; } /** diff --git a/app/Actions/Post/ImportExternalPosts.php b/app/Actions/Post/ImportExternalPosts.php index 22e757c434..0aed5d97f1 100644 --- a/app/Actions/Post/ImportExternalPosts.php +++ b/app/Actions/Post/ImportExternalPosts.php @@ -8,13 +8,12 @@ use App\Enums\Analytics\PublicationContentType; use App\Enums\Analytics\PublicationOrigin; use App\Enums\Post\Origin; +use App\Enums\Post\PublishStatus; use App\Enums\Post\Status as PostStatus; use App\Enums\PostPlatform\ContentType; -use App\Enums\PostPlatform\Status as PostPlatformStatus; use App\Enums\SocialAccount\Platform; use App\Models\AnalyticsPublication; use App\Models\Post; -use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Services\Social\ContentSanitizer; use App\Support\PostHistoryRetention; @@ -107,8 +106,8 @@ private static function newestUnlinked(SocialAccount $account, int $limit, bool ->orderByDesc('provider_published_at') ->orderByDesc('id') ->limit($limit) - ->get(['id', 'post_platform_id', 'post_dismissed_at']) - ->filter(fn (AnalyticsPublication $publication): bool => $publication->post_platform_id === null && $publication->post_dismissed_at === null) + ->get(['id', 'post_id', 'post_dismissed_at']) + ->filter(fn (AnalyticsPublication $publication): bool => $publication->post_id === null && $publication->post_dismissed_at === null) ->pluck('id') ->values() ->all(); @@ -119,15 +118,15 @@ private static function import(SocialAccount $account, string $publicationId): ? return DB::transaction(function () use ($account, $publicationId): ?string { $publication = AnalyticsPublication::query()->lockForUpdate()->find($publicationId); - if ($publication === null || $publication->post_platform_id !== null || $publication->post_dismissed_at !== null) { + if ($publication === null || $publication->post_id !== null || $publication->post_dismissed_at !== null) { return null; } $existing = self::tryPostTarget($account, $publication); if ($existing !== null) { - if (! AnalyticsPublication::query()->where('post_platform_id', $existing->id)->exists()) { - $publication->update(['post_platform_id' => $existing->id]); + if (! AnalyticsPublication::query()->where('post_id', $existing->id)->exists()) { + $publication->update(['post_id' => $existing->id]); } return null; @@ -141,14 +140,14 @@ private static function import(SocialAccount $account, string $publicationId): ? return null; } - $postPlatform = Post::withoutEvents(fn (): PostPlatform => self::createPost($account, $publication)); - $publication->update(['post_platform_id' => $postPlatform->id]); + $post = Post::withoutEvents(fn (): Post => self::createPost($account, $publication)); + $publication->update(['post_id' => $post->id]); - return $postPlatform->post_id; + return $post->id; }); } - private static function tryPostTarget(SocialAccount $account, AnalyticsPublication $publication): ?PostPlatform + private static function tryPostTarget(SocialAccount $account, AnalyticsPublication $publication): ?Post { $remoteIds = collect([ $publication->remote_id, @@ -156,10 +155,10 @@ private static function tryPostTarget(SocialAccount $account, AnalyticsPublicati $account->platform === Platform::Facebook ? data_get($publication->provider_metadata, 'photo_id') : null, ])->filter(fn (mixed $id): bool => is_string($id) && filled($id))->values()->all(); - return PostPlatform::query() + return Post::query() ->where('social_account_id', $account->id) ->whereIn('platform_post_id', $remoteIds) - ->whereHas('post', fn (Builder $post): Builder => $post->where('workspace_id', $account->workspace_id)) + ->where('workspace_id', $account->workspace_id) ->first(); } @@ -171,7 +170,7 @@ private static function isTryPostThreadSegment(SocialAccount $account, Analytics { $remoteId = $publication->remote_id; - return filled($remoteId) && PostPlatform::query() + return filled($remoteId) && Post::query() ->where('social_account_id', $account->id) ->where(fn (Builder $query): Builder => $query ->whereJsonContains('thread_reply_ids', $remoteId) @@ -197,14 +196,14 @@ private static function claimedBySentPost(SocialAccount $account, AnalyticsPubli if ($targets->count() > 1 || blank(Str::squish((string) $publication->excerpt)) - || $targets->contains(fn (PostPlatform $target): bool => $target->status !== PostPlatformStatus::Published)) { + || $targets->contains(fn (Post $target): bool => $target->publish_status !== PublishStatus::Published)) { if (! Cache::add("import-external-posts:ambiguous:{$publication->id}", true, now()->addDay())) { return true; } Log::warning('External publication identity is ambiguous; not imported.', [ 'analytics_publication_id' => $publication->id, - 'post_platform_ids' => $targets->modelKeys(), + 'post_ids' => $targets->modelKeys(), ]); return true; @@ -218,17 +217,18 @@ private static function claimedBySentPost(SocialAccount $account, AnalyticsPubli return true; } - $publication->update(['post_platform_id' => $target->id]); - PostPlatform::query()->whereKey($target->id)->update([ + $publication->update(['post_id' => $target->id]); + Post::query()->whereKey($target->id)->toBase()->update([ 'platform_post_id' => $publication->remote_id, 'platform_url' => $publication->permalink ?? $target->platform_url, + 'publication_updated_at' => now(), ]); return true; } /** - * @return Collection + * @return Collection */ private static function matchingTryPostTargets(SocialAccount $account, AnalyticsPublication $publication): Collection { @@ -241,22 +241,22 @@ private static function matchingTryPostTargets(SocialAccount $account, Analytics $publishedAt = $publication->provider_published_at->toImmutable(); - return PostPlatform::query() + return Post::query() ->where('social_account_id', $account->id) - ->enabled() ->where(fn (Builder $query): Builder => self::matchingPublicationState($query, $account->platform, $publishedAt, $window)) ->whereIn('content_type', self::compatibleContentTypes(ContentType::fromPublication($account->platform, $publication->content_type))) - ->whereHas('post', fn (Builder $post): Builder => $post->createdInTryPost()->where('workspace_id', $account->workspace_id)) + ->createdInTryPost() + ->where('workspace_id', $account->workspace_id) ->where(fn (Builder $query): Builder => $query ->whereDoesntHave('analyticsPublication') ->orWhereHas('analyticsPublication', fn (Builder $linked): Builder => $linked ->where('origin', PublicationOrigin::TryPost) ->whereNull('provider_synced_at') - ->whereColumn('analytics_publications.remote_id', 'post_platforms.platform_post_id'))) - ->with(['post:id,content', 'analyticsPublication']) + ->whereColumn('analytics_publications.remote_id', 'posts.platform_post_id'))) + ->with('analyticsPublication') ->get() - ->filter(fn (PostPlatform $target): bool => self::sameText( - $target->content_type->isCaptionless() ? '' : (string) $target->post?->content, + ->filter(fn (Post $target): bool => self::sameText( + $target->content_type->isCaptionless() ? '' : (string) $target->content, $text, $account->platform, )) @@ -267,18 +267,18 @@ private static function matchingPublicationState(Builder $query, Platform $platf { $interval = [$publishedAt->subMinutes($window), $publishedAt->addMinutes($window)]; - $query->where(fn (Builder $sent): Builder => $sent->published()->whereBetween('published_at', $interval)); + $query->where(fn (Builder $sent): Builder => $sent->publicationPublished()->whereBetween('published_at', $interval)); if (! in_array($platform, [Platform::Instagram, Platform::InstagramFacebook], true)) { return $query; } return $query->orWhere(fn (Builder $pending): Builder => $pending - ->whereBetween('updated_at', $interval) + ->whereBetween('publication_updated_at', $interval) ->where(fn (Builder $state): Builder => $state - ->where('status', PostPlatformStatus::Publishing) + ->where('publish_status', PublishStatus::Publishing) ->orWhere(fn (Builder $retry): Builder => $retry - ->where('status', PostPlatformStatus::Retrying) + ->where('publish_status', PublishStatus::Retrying) ->whereNotNull('error_context->'.PublishCheckpoint::INSTAGRAM_WORKFLOW.'->container_id')))); } @@ -322,11 +322,9 @@ private static function sameText(string $content, string $text, Platform $platfo && Str::startsWith($sent, $truncated); } - private static function createPost(SocialAccount $account, AnalyticsPublication $publication): PostPlatform + private static function createPost(SocialAccount $account, AnalyticsPublication $publication): Post { - $publishedAt = $publication->provider_published_at; - - $post = Post::query()->create([ + return Post::query()->forceCreate([ 'workspace_id' => $account->workspace_id, 'post_group_id' => (string) Str::uuid7(), 'user_id' => null, @@ -334,19 +332,14 @@ private static function createPost(SocialAccount $account, AnalyticsPublication 'media' => [], 'status' => PostStatus::Published, 'origin' => Origin::Network, - 'published_at' => $publishedAt, - ]); - - return $post->postPlatforms()->create([ + 'published_at' => $publication->provider_published_at, 'social_account_id' => $account->id, 'platform' => $account->platform, ...$account->channelSnapshot(), 'content_type' => ContentType::fromPublication($account->platform, $publication->content_type), - 'status' => PostPlatformStatus::Published, - 'enabled' => true, + 'publish_status' => PublishStatus::Published, 'platform_post_id' => $publication->remote_id, 'platform_url' => $publication->permalink, - 'published_at' => $publishedAt, 'meta' => [], ]); } diff --git a/app/Actions/Post/PruneExpiredPostHistory.php b/app/Actions/Post/PruneExpiredPostHistory.php index 06cf5f542f..94283fbb30 100644 --- a/app/Actions/Post/PruneExpiredPostHistory.php +++ b/app/Actions/Post/PruneExpiredPostHistory.php @@ -18,7 +18,7 @@ class PruneExpiredPostHistory public static function execute(CarbonInterface $cutoff, bool $dryRun = false): int { $query = Post::query() - ->whereIn('status', [Status::Published, Status::PartiallyPublished]) + ->where('status', Status::Published) ->whereNotNull('published_at') ->where('published_at', '<', $cutoff); diff --git a/app/Actions/Post/Queue/BuildQueueTimeline.php b/app/Actions/Post/Queue/BuildQueueTimeline.php index cc2f3a55f1..cf3a2c4dd0 100644 --- a/app/Actions/Post/Queue/BuildQueueTimeline.php +++ b/app/Actions/Post/Queue/BuildQueueTimeline.php @@ -27,16 +27,15 @@ public static function handle(Workspace $workspace, Collection $channels, string ->where('workspace_id', $workspace->id) ->holdingSlot() ->whereBetween('scheduled_at', [now(), $until]) - ->whereHas('postPlatforms', fn ($platforms) => $platforms->enabled()->whereIn('social_account_id', $channelIds)) + ->whereIn('social_account_id', $channelIds) ->matchingLabelFilter($labelIds, $untagged) - ->with(['postPlatforms' => fn ($platforms) => $platforms->enabled()->whereIn('social_account_id', $channelIds)->orderBy('created_at')->orderBy('id')]) ->get(); $items = []; $occupied = []; foreach ($posts as $post) { - $channelId = data_get($post->postPlatforms->first(), 'social_account_id'); + $channelId = $post->social_account_id; if ($channelId === null) { continue; diff --git a/app/Actions/Post/Queue/ListTakenSlots.php b/app/Actions/Post/Queue/ListTakenSlots.php index d15b22891b..db25f5b2d7 100644 --- a/app/Actions/Post/Queue/ListTakenSlots.php +++ b/app/Actions/Post/Queue/ListTakenSlots.php @@ -5,9 +5,7 @@ namespace App\Actions\Post\Queue; use App\Models\Post; -use App\Models\PostPlatform; use Carbon\CarbonInterface; -use Illuminate\Database\Eloquent\Builder; class ListTakenSlots { @@ -21,22 +19,18 @@ class ListTakenSlots */ public static function handle(array $channelIds, CarbonInterface $from, CarbonInterface $until): array { - $taken = PostPlatform::query() - ->enabled() - ->whereIn('post_platforms.social_account_id', $channelIds) - ->whereHas('post', fn (Builder $post): Builder => $post->holdingSlot() - ->where('scheduled_at', '>=', $from) - ->where('scheduled_at', '<', $until)) - ->select('post_platforms.social_account_id') - ->addSelect(['slot_at' => Post::query()->select('scheduled_at')->whereColumn('posts.id', 'post_platforms.post_id')]) - ->withCasts(['slot_at' => 'datetime']) - ->get() + $taken = Post::query() + ->whereIn('social_account_id', $channelIds) + ->holdingSlot() + ->where('scheduled_at', '>=', $from) + ->where('scheduled_at', '<', $until) + ->get(['social_account_id', 'scheduled_at']) ->groupBy('social_account_id'); return collect($channelIds) ->mapWithKeys(fn (string $channelId): array => [$channelId => $taken->get($channelId, collect()) - ->sortBy(fn (PostPlatform $platform): int => $platform->slot_at->getTimestamp()) - ->map(fn (PostPlatform $platform): string => $platform->slot_at->toIso8601ZuluString()) + ->sortBy(fn (Post $post): int => $post->scheduled_at->getTimestamp()) + ->map(fn (Post $post): string => $post->scheduled_at->toIso8601ZuluString()) ->unique() ->values() ->all()]) diff --git a/app/Actions/Post/Queue/MoveChannelPostToQueueSlot.php b/app/Actions/Post/Queue/MoveChannelPostToQueueSlot.php index d635f88b90..969a75e3ea 100644 --- a/app/Actions/Post/Queue/MoveChannelPostToQueueSlot.php +++ b/app/Actions/Post/Queue/MoveChannelPostToQueueSlot.php @@ -33,10 +33,6 @@ public static function handle(SocialAccount $channel, string $postId, CarbonImmu throw ValidationException::withMessages(['slot_at' => __('posts.errors.queue_order_stale')]); } - if ($post->postPlatforms()->enabled()->count() !== 1) { - throw ValidationException::withMessages(['queue' => __('posts.errors.queue_legacy_post')]); - } - UpdatePost::execute($channel->workspace, $post, [ 'status' => PostStatus::Scheduled->value, 'queue_slot' => $slotAt->utc()->toIso8601String(), diff --git a/app/Actions/Post/Queue/ReflowChannelQueue.php b/app/Actions/Post/Queue/ReflowChannelQueue.php index 7b0031d5d5..436b80b85b 100644 --- a/app/Actions/Post/Queue/ReflowChannelQueue.php +++ b/app/Actions/Post/Queue/ReflowChannelQueue.php @@ -170,7 +170,7 @@ public static function handleLocked(SocialAccount $channel, ?Post $insert = null $posts = $insert ? $queued->push($insert) : $queued; DB::transaction(function () use ($posts, $targets): void { - foreach ($posts as $post) { + foreach ($posts->sortBy('id', SORT_STRING) as $post) { $slot = $targets[$post->id] ?? null; if ($slot === null) { diff --git a/app/Actions/Post/Queue/ReorderChannelQueue.php b/app/Actions/Post/Queue/ReorderChannelQueue.php index b211285099..f0007bec46 100644 --- a/app/Actions/Post/Queue/ReorderChannelQueue.php +++ b/app/Actions/Post/Queue/ReorderChannelQueue.php @@ -35,12 +35,14 @@ public static function handle(SocialAccount $channel, array $postIds): void $slots = $queued->pluck('scheduled_at')->sort()->values(); - DB::transaction(function () use ($postIds, $queued, $slots): void { - foreach (array_values($postIds) as $index => $postId) { + $slotsById = collect(array_values($postIds))->mapWithKeys(fn (string $postId, int $index): array => [$postId => $slots[$index]]); + + DB::transaction(function () use ($queued, $slotsById): void { + foreach ($slotsById->sortKeys(SORT_STRING) as $postId => $slot) { $post = $queued->get($postId); - if (! $post->scheduled_at->equalTo($slots[$index])) { - ReflowChannelQueue::updateIfScheduled($post, ['scheduled_at' => $slots[$index]]); + if (! $post->scheduled_at->equalTo($slot)) { + ReflowChannelQueue::updateIfScheduled($post, ['scheduled_at' => $slot]); } } }); diff --git a/app/Actions/Post/RecoverEmptyDraft.php b/app/Actions/Post/RecoverEmptyDraft.php index 85261411cb..8b0a5f1f83 100644 --- a/app/Actions/Post/RecoverEmptyDraft.php +++ b/app/Actions/Post/RecoverEmptyDraft.php @@ -34,7 +34,7 @@ public static function execute(Workspace $workspace, User $user, Post $legacy, a beforeCreate: function (MediaCopyBatch $batch) use ($workspace, $legacy, &$locked): void { $locked = $workspace->posts()->lockForUpdate()->findOrFail($legacy->id); - if ($locked->status !== Status::Draft || $locked->postPlatforms()->enabled()->exists()) { + if ($locked->status !== Status::Draft || $locked->hasDestination()) { throw ValidationException::withMessages([ 'recover_post_id' => __('validation.in', ['attribute' => 'recovery post']), ]); diff --git a/app/Actions/Post/ScheduleNextOccurrence.php b/app/Actions/Post/ScheduleNextOccurrence.php index 8af576b694..8fa5f0799e 100644 --- a/app/Actions/Post/ScheduleNextOccurrence.php +++ b/app/Actions/Post/ScheduleNextOccurrence.php @@ -7,7 +7,6 @@ use App\Enums\Post\RecurrenceFrequency; use App\Enums\Post\Status as PostStatus; use App\Models\Post; -use App\Models\PostPlatform; use App\Support\Media\MediaCopyBatch; use App\Support\PostingSchedule; use App\Support\Timezone; @@ -39,7 +38,7 @@ public static function execute(Post $post): Collection } $current = $post->currentOccurrence(); - $targets = $post->postPlatforms()->enabled()->whereHas('socialAccount')->with('socialAccount')->get(); + $targets = $post->loadMissing('socialAccount')->socialAccount !== null ? collect([$post]) : collect(); $author = $post->user; $user = $author !== null && ($author->ownsAccountOf($post->workspace) || $author->belongsToWorkspace($post->workspace)) ? $author @@ -57,7 +56,7 @@ public static function execute(Post $post): Collection $ceiling = CarbonImmutable::parse(PostingSchedule::MAX_INSTANT, 'UTC'); $plans = $targets - ->map(function (PostPlatform $target) use ($post, $current, $frequency, $interval, $remaining): array { + ->map(function (Post $target) use ($post, $current, $frequency, $interval, $remaining): array { [$origin, $index] = self::position($post->recurrence_origin_at, $current, $frequency, $interval, Timezone::normalize($target->socialAccount->timezone)); $steps = 1; @@ -84,7 +83,7 @@ public static function execute(Post $post): Collection $groupId = (string) Str::uuid7(); $occurrences = $plans->map(function (array $plan) use ($post, $user, $batch, $groupId, $frequency, $interval): Post { $occurrence = CreateChannelPost::execute($post->workspace, $user, [ - ...DuplicatePost::destination($post, $plan['target']), + ...DuplicatePost::destination($post), 'post_group_id' => $groupId, 'status' => PostStatus::Scheduled->value, 'scheduled_at' => $plan['next']->utc()->toIso8601String(), diff --git a/app/Actions/Post/UpdatePost.php b/app/Actions/Post/UpdatePost.php index c01070febc..aef018300d 100644 --- a/app/Actions/Post/UpdatePost.php +++ b/app/Actions/Post/UpdatePost.php @@ -14,11 +14,8 @@ use App\Enums\Post\ScheduleMode; use App\Enums\Post\Status as PostStatus; use App\Enums\PostPlatform\ContentType; -use App\Enums\PostPlatform\Status as PlatformStatus; -use App\Enums\SocialAccount\Platform; use App\Jobs\PublishPost; use App\Models\Post; -use App\Models\PostPlatform; use App\Models\User; use App\Models\Workspace; use App\Support\Media\MediaCopyBatch; @@ -26,12 +23,9 @@ use App\Support\PostCompositionValidator; use App\Support\PostPlatformMetaRules; use App\Support\PostStatusRules; -use App\Support\Social\AbandonGoogleBusinessReview; -use App\Support\Social\GoogleBusinessDerivativeCleaner; use Carbon\Carbon; use Carbon\CarbonInterface; use Illuminate\Support\Arr; -use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; class UpdatePost @@ -64,60 +58,45 @@ private static function apply(Workspace $workspace, Post $post, array $data, ?Us throw ValidationException::withMessages(['social_account_id' => __('validation.in', ['attribute' => 'social account'])]); } - if ($post->postPlatforms()->enabled()->count() === 1) { - $selectedTarget = $post->postPlatforms()->enabled()->sole(); - if (array_key_exists('platforms', $data)) { - if (count($data['platforms']) !== 1 || data_get($data, 'platforms.0.id') !== $selectedTarget->id - || array_key_exists('content_type', $data) || array_key_exists('meta', $data)) { - throw ValidationException::withMessages(['platforms' => __('validation.in', ['attribute' => 'platforms'])]); - } - - $data = [ - ...Arr::except($data, ['platforms']), - ...Arr::only($data['platforms'][0], ['content_type', 'meta']), - ]; - } - - return self::updateChannelPost($workspace, $post, $data, $actor); + if (array_key_exists('platforms', $data)) { + throw ValidationException::withMessages(['platforms' => __('validation.prohibited', ['attribute' => 'platforms'])]); } - if (filled(data_get($data, 'queue'))) { - throw ValidationException::withMessages(['queue' => __('posts.errors.queue_legacy_post')]); + return $post->hasChannel() + ? self::updateChannelPost($workspace, $post, $data, $actor) + : self::updateDraftWithoutChannel($post, $data); + } + + /** + * A draft written before posts had one channel each can still be edited, + * but it stays a draft: it goes out only after the composer turns it into + * a post for a channel (RecoverEmptyDraft). + * + * @param array $data + * @return array{post: Post, action: PostAction|null} + */ + private static function updateDraftWithoutChannel(Post $post, array $data): array + { + $status = (string) data_get($data, 'status', $post->status->value); + + if ($status !== PostStatus::Draft->value || filled(data_get($data, 'queue')) || filled(data_get($data, 'queue_slot'))) { + throw ValidationException::withMessages(['status' => __('posts.errors.choose_channel')]); } if (array_key_exists('content_type', $data) || array_key_exists('meta', $data)) { - return self::updateChannelPost($workspace, $post, $data, $actor); + throw ValidationException::withMessages(['content_type' => __('posts.errors.choose_channel')]); } - return MediaCopyBatch::run(function (MediaCopyBatch $batch) use ($workspace, $post, $data, $actor): array { + return MediaCopyBatch::run(function (MediaCopyBatch $batch) use ($post, $data): array { if (self::finalizedMeanwhile($post)) { return ['post' => $post, 'action' => PostAction::Finalized]; } - $occurrence = $post->currentOccurrence(); - $previousStatus = $post->status; - $scheduledAt = $post->scheduled_at; - if (data_get($data, 'scheduled_at')) { - $scheduledAt = Carbon::parse(data_get($data, 'scheduled_at'))->utc(); - } - - $status = (string) data_get($data, 'status', $post->status->value); - $storedStatus = PostApproval::isRequired($workspace, $actor, $status) - ? PostStatus::PendingApproval - : PostStatus::from($status); - $publishRequest = ($storedStatus === PostStatus::PendingApproval || $previousStatus === PostStatus::PendingApproval) - && $status === PostStatus::Publishing->value; - $post->update([ 'content' => data_get($data, 'content', $post->content), - 'status' => $storedStatus, - 'scheduled_at' => $publishRequest ? null : $scheduledAt, - 'schedule_mode' => match (true) { - $storedStatus === PostStatus::Scheduled => ScheduleMode::Custom, - $storedStatus === PostStatus::PendingApproval && ! $publishRequest && $scheduledAt !== null => ScheduleMode::Custom, - default => null, - }, - ...PostApproval::transition($previousStatus, $storedStatus, $actor), + 'scheduled_at' => array_key_exists('scheduled_at', $data) && filled(data_get($data, 'scheduled_at')) + ? Carbon::parse(data_get($data, 'scheduled_at'))->utc() + : $post->scheduled_at, ]); if (Arr::has($data, 'media')) { @@ -128,65 +107,7 @@ private static function apply(Workspace $workspace, Post $post, array $data, ?Us $post->labels()->sync(data_get($data, 'label_ids', [])); } - if (Arr::has($data, 'platforms')) { - $post->postPlatforms()->update(['enabled' => false]); - - foreach (data_get($data, 'platforms', []) as $platformData) { - $updateData = ['enabled' => true]; - - if (data_get($platformData, 'content_type') !== null) { - $updateData['content_type'] = data_get($platformData, 'content_type'); - } - - if (data_get($platformData, 'meta') !== null) { - $postPlatform = $post->postPlatforms()->where('id', data_get($platformData, 'id'))->first(); - - if ($postPlatform) { - $updateData['meta'] = PostPlatformMetaRules::forStorage($postPlatform->meta ?? [], PostPlatformMetaRules::normalize(array_filter( - array_merge($postPlatform->meta ?? [], data_get($platformData, 'meta') ?? []), - fn (mixed $value): bool => $value !== null, - ))); - } - } - - $post->postPlatforms() - ->where('id', data_get($platformData, 'id')) - ->update($updateData); - } - } - - if (in_array($post->status, [PostStatus::Scheduled, PostStatus::Publishing], true)) { - PostPlatformMetaRules::assertStoredPostPublishable( - $post, - collect(data_get($data, 'platforms', []))->pluck('id')->all(), - ); - } - - if (Arr::has($data, 'platforms')) { - $post->postPlatforms() - ->disabled() - ->where('platform', Platform::GoogleBusiness) - ->where('status', PlatformStatus::PendingReview) - ->get() - ->each(fn (PostPlatform $platform) => AbandonGoogleBusinessReview::execute( - $platform, - __('posts.errors.target_disabled'), - ['category' => 'target_disabled'], - )); - - $disabledGoogleBusinessIds = $post->postPlatforms() - ->disabled() - ->where('platform', Platform::GoogleBusiness) - ->pluck('id'); - - DB::afterCommit(function () use ($disabledGoogleBusinessIds): void { - $disabledGoogleBusinessIds->each( - fn (string $id) => app(GoogleBusinessDerivativeCleaner::class)->cleanup($id), - ); - }); - } - - return self::finish($post, $previousStatus, $storedStatus, $occurrence, $actor); + return ['post' => $post, 'action' => null]; }); } @@ -196,17 +117,8 @@ private static function apply(Workspace $workspace, Post $post, array $data, ?Us */ private static function updateChannelPost(Workspace $workspace, Post $post, array $data, ?User $actor): array { - if (array_key_exists('platforms', $data)) { - throw ValidationException::withMessages(['platforms' => __('validation.in', ['attribute' => 'platforms'])]); - } - - if ($post->postPlatforms()->enabled()->count() !== 1) { - throw ValidationException::withMessages(['post' => PostStatusRules::editBlockedMessage()]); - } - - $target = $post->postPlatforms()->enabled()->sole(); $position = filled(data_get($data, 'queue')) ? QueuePosition::from(data_get($data, 'queue')) : null; - $channel = $target->socialAccount; + $channel = $post->socialAccount; if ($position !== null && ! $channel?->hasPostingSchedule()) { throw ValidationException::withMessages(['queue' => __('posts.errors.queue_requires_schedule')]); @@ -239,7 +151,7 @@ private static function updateChannelPost(Workspace $workspace, Post $post, arra $queueSlot = $position === null && ! $keepsPending ? data_get($data, 'queue_slot') : null; $meta = PostPlatformMetaRules::normalize(array_filter( - array_merge($target->meta ?? [], data_get($data, 'meta') ?? []), + array_merge($post->meta ?? [], data_get($data, 'meta') ?? []), fn (mixed $value): bool => $value !== null, )); $scheduledAt = match (true) { @@ -270,14 +182,14 @@ private static function updateChannelPost(Workspace $workspace, Post $post, arra 'scheduled_at' => $keepsPending ? null : $scheduledAt, 'label_ids' => data_get($data, 'label_ids') ?? $post->labels()->pluck('workspace_labels.id')->all(), 'destinations' => [[ - 'social_account_id' => $target->social_account_id, + 'social_account_id' => $post->social_account_id, 'content_type' => data_get($data, 'content_type') - ?? (array_key_exists('media', $data) && ContentType::derivesFromMedia($target->platform) ? null : $target->content_type->value), + ?? (array_key_exists('media', $data) && ContentType::derivesFromMedia($post->platform) ? null : $post->content_type->value), 'meta' => $meta, ]], ], $post->media ?? []); - $write = fn (): array => MediaCopyBatch::run(function (MediaCopyBatch $batch) use ($post, $target, $channel, $data, $resolved, $scheduledAt, $mode, $position, $pending, $approvesHolder, $previousStatus, $storedStatus, $actor): array { + $write = fn (): array => MediaCopyBatch::run(function (MediaCopyBatch $batch) use ($post, $channel, $data, $resolved, $scheduledAt, $mode, $position, $pending, $approvesHolder, $previousStatus, $storedStatus, $actor): array { if (self::finalizedMeanwhile($post)) { return ['post' => $post, 'action' => PostAction::Finalized]; } @@ -292,10 +204,8 @@ private static function updateChannelPost(Workspace $workspace, Post $post, arra 'scheduled_at' => $scheduledAt && ! $slotLost ? Carbon::parse($scheduledAt)->utc() : null, 'schedule_mode' => $mode, ...PostApproval::transition($previousStatus, $storedStatus, $actor, $pending ? $position : null), - ]); - $target->update([ 'content_type' => $destination['content_type'], - 'meta' => PostPlatformMetaRules::forStorage($target->meta ?? [], $destination['meta']), + 'meta' => PostPlatformMetaRules::forStorage($post->meta ?? [], $destination['meta']), ]); SyncOwnedMedia::execute($post, $destination['media'], $batch); @@ -312,7 +222,7 @@ private static function updateChannelPost(Workspace $workspace, Post $post, arra return ($position === null && ! $approvesHolder) || $pending ? $write() - : ReflowChannelQueue::withLock([$target->social_account_id], $write); + : ReflowChannelQueue::withLock([$post->social_account_id], $write); } /** diff --git a/app/Actions/Repurpose/ListRepurposeItems.php b/app/Actions/Repurpose/ListRepurposeItems.php index 30637a149d..0cc1d2eb3f 100644 --- a/app/Actions/Repurpose/ListRepurposeItems.php +++ b/app/Actions/Repurpose/ListRepurposeItems.php @@ -17,7 +17,7 @@ class ListRepurposeItems public static function execute(Repurpose $repurpose, ?int $page = null): LengthAwarePaginator { return $repurpose->items() - ->with('posts.postPlatforms:id,post_id,platform,enabled,status') + ->with('posts:id,repurpose_item_id,platform,publish_status') ->orderByDesc(DB::raw('coalesce(source_created_at, created_at)')) ->paginate((int) config('app.pagination.default'), page: $page); } diff --git a/app/Actions/SocialAccount/CountPostsSentThisWeek.php b/app/Actions/SocialAccount/CountPostsSentThisWeek.php index 3c75dffc80..bbd8489992 100644 --- a/app/Actions/SocialAccount/CountPostsSentThisWeek.php +++ b/app/Actions/SocialAccount/CountPostsSentThisWeek.php @@ -5,7 +5,7 @@ namespace App\Actions\SocialAccount; use App\Enums\User\WeekStart; -use App\Models\PostPlatform; +use App\Models\Post; use App\Models\SocialAccount; use App\Support\Timezone; @@ -19,10 +19,9 @@ public static function handle(SocialAccount $channel, WeekStart $weekStart): int { $now = now(Timezone::normalize($channel->timezone)); - return PostPlatform::query() + return Post::query() ->where('social_account_id', $channel->id) - ->enabled() - ->published() + ->publicationPublished() ->whereNotNull('published_at') ->whereBetween('published_at', [ $now->startOfWeek($weekStart->firstDay())->utc(), diff --git a/app/Actions/SocialAccount/ListInstagramGridPosts.php b/app/Actions/SocialAccount/ListInstagramGridPosts.php index 7d4514768d..205cf69f59 100644 --- a/app/Actions/SocialAccount/ListInstagramGridPosts.php +++ b/app/Actions/SocialAccount/ListInstagramGridPosts.php @@ -5,7 +5,7 @@ namespace App\Actions\SocialAccount; use App\Enums\PostPlatform\ContentType; -use App\Models\PostPlatform; +use App\Models\Post; use App\Models\SocialAccount; use Illuminate\Pagination\LengthAwarePaginator; @@ -16,17 +16,15 @@ class ListInstagramGridPosts { /** - * @return LengthAwarePaginator + * @return LengthAwarePaginator */ public static function execute(SocialAccount $account): LengthAwarePaginator { - return PostPlatform::query() - ->select(['id', 'post_id', 'content_type', 'published_at']) + return Post::query() + ->select(['id', 'media', 'content_type', 'published_at']) ->where('social_account_id', $account->id) - ->enabled() - ->published() + ->publicationPublished() ->whereIn('content_type', [ContentType::InstagramFeed, ContentType::InstagramReel]) - ->with('post:id,media') ->orderByDesc('published_at') ->orderByDesc('id') ->paginate((int) config('app.pagination.default')); diff --git a/app/Actions/SocialAccount/StoreTelegramReactions.php b/app/Actions/SocialAccount/StoreTelegramReactions.php index 050a5d9b5f..528845db82 100644 --- a/app/Actions/SocialAccount/StoreTelegramReactions.php +++ b/app/Actions/SocialAccount/StoreTelegramReactions.php @@ -5,7 +5,7 @@ namespace App\Actions\SocialAccount; use App\Enums\SocialAccount\Platform; -use App\Models\PostPlatform; +use App\Models\Post; class StoreTelegramReactions { @@ -24,13 +24,13 @@ public static function execute(array $update): void return; } - $postPlatform = PostPlatform::query() + $post = Post::query() ->where('platform', Platform::Telegram->value) ->where('platform_post_id', $messageId) ->whereHas('socialAccount', fn ($query) => $query->where('meta->chat_id', $chatId)) ->first(); - if ($postPlatform === null) { + if ($post === null) { return; } @@ -41,6 +41,6 @@ public static function execute(array $update): void 'count' => (int) data_get($reaction, 'total_count'), ], is_array($rawReactions) ? $rawReactions : [])); - $postPlatform->update(['meta' => [...$postPlatform->meta ?? [], 'reactions' => $reactions]]); + $post->writePublication(['meta' => [...$post->meta ?? [], 'reactions' => $reactions]]); } } diff --git a/app/Actions/Workspace/PurgeWorkspace.php b/app/Actions/Workspace/PurgeWorkspace.php index 271cf0a3dd..30db9ea605 100644 --- a/app/Actions/Workspace/PurgeWorkspace.php +++ b/app/Actions/Workspace/PurgeWorkspace.php @@ -6,7 +6,7 @@ use App\Actions\Media\DeleteOwnedMedia; use App\Enums\SocialAccount\Platform; -use App\Models\PostPlatform; +use App\Models\Post; use App\Models\Workspace; use App\Support\Social\GoogleBusinessDerivativeCleaner; @@ -18,11 +18,10 @@ class PurgeWorkspace */ public static function execute(Workspace $workspace): void { - PostPlatform::query() + $workspace->posts() ->where('platform', Platform::GoogleBusiness) - ->whereIn('post_id', $workspace->posts()->select('id')) - ->pluck('id') - ->each(fn (string $id) => app(GoogleBusinessDerivativeCleaner::class)->cleanup($id)); + ->select(['id', 'legacy_target_id']) + ->each(fn (Post $post) => app(GoogleBusinessDerivativeCleaner::class)->cleanup($post)); DeleteOwnedMedia::forWorkspace($workspace); $workspace->delete(); diff --git a/app/Console/Commands/CheckUpcomingPostConnections.php b/app/Console/Commands/CheckUpcomingPostConnections.php index 11caf182d9..709f444ce7 100644 --- a/app/Console/Commands/CheckUpcomingPostConnections.php +++ b/app/Console/Commands/CheckUpcomingPostConnections.php @@ -4,10 +4,9 @@ namespace App\Console\Commands; -use App\Enums\Post\Status as PostStatus; -use App\Enums\PostPlatform\Status as PostPlatformStatus; +use App\Enums\Post\PublishStatus; use App\Jobs\VerifyUpcomingPostConnections; -use App\Models\PostPlatform; +use App\Models\Post; use Illuminate\Console\Command; class CheckUpcomingPostConnections extends Command @@ -18,23 +17,20 @@ class CheckUpcomingPostConnections extends Command public function handle(): void { - $workspaceIds = PostPlatform::query() - ->where('post_platforms.status', PostPlatformStatus::Pending) - ->enabled() + $workspaceIds = Post::query() + ->scheduled() + ->whereBetween('scheduled_at', [now(), now()->addHour()]) + ->where('publish_status', PublishStatus::Pending) ->whereHas('socialAccount') ->where(function ($query) { - $query->whereNull('post_platforms.connection_warning_sent_at') - ->orWhere('post_platforms.connection_warning_sent_at', '<', now()->subDay()); + $query->whereNull('connection_warning_sent_at') + ->orWhere('connection_warning_sent_at', '<', now()->subDay()); }) - ->join('posts', 'posts.id', '=', 'post_platforms.post_id') - ->where('posts.status', PostStatus::Scheduled) - ->whereBetween('posts.scheduled_at', [now(), now()->addHour()]) ->distinct() - ->pluck('posts.workspace_id'); + ->pluck('workspace_id'); foreach ($workspaceIds as $workspaceId) { VerifyUpcomingPostConnections::dispatch($workspaceId); } - } } diff --git a/app/Console/Commands/ProcessScheduledPosts.php b/app/Console/Commands/ProcessScheduledPosts.php index 9f762c8989..61b7bc619c 100644 --- a/app/Console/Commands/ProcessScheduledPosts.php +++ b/app/Console/Commands/ProcessScheduledPosts.php @@ -8,7 +8,6 @@ use App\Jobs\PublishPost; use App\Jobs\PublishToSocialPlatform; use App\Models\Post; -use App\Models\PostPlatform; use App\Support\Social\LimitRetryPolicy; use Illuminate\Console\Command; use Throwable; @@ -41,26 +40,26 @@ public function handle(): void } /** - * Targets a network refused for a limit wait in the database, not in a + * Posts a network refused for a limit wait in the database, not in a * delayed job, so a worker restart never loses them. Clearing retry_at - * claims the target, so each retry is dispatched once. + * claims the post, so each retry is dispatched once. */ private function dispatchDueLimitRetries(): void { - PostPlatform::query() - ->enabled() + Post::query() ->dueForLimitRetry() - ->each(function (PostPlatform $postPlatform): void { + ->each(function (Post $post): void { try { - $claimed = PostPlatform::query() - ->whereKey($postPlatform->id) + $claimed = Post::query() + ->whereKey($post->id) ->dueForLimitRetry() - ->update(['retry_at' => null]); + ->toBase() + ->update(['retry_at' => null, 'publication_updated_at' => now()]); if ($claimed) { PublishToSocialPlatform::dispatch( - $postPlatform, - LimitRetryPolicy::UNIQUE_ATTEMPT_OFFSET + LimitRetryPolicy::retriesSoFar($postPlatform->error_context), + $post, + LimitRetryPolicy::UNIQUE_ATTEMPT_OFFSET + LimitRetryPolicy::retriesSoFar($post->error_context), ); } } catch (Throwable $exception) { diff --git a/app/Console/Commands/ReconcileGoogleBusinessPosts.php b/app/Console/Commands/ReconcileGoogleBusinessPosts.php index 1e576643dc..b6ed0ba73c 100644 --- a/app/Console/Commands/ReconcileGoogleBusinessPosts.php +++ b/app/Console/Commands/ReconcileGoogleBusinessPosts.php @@ -4,10 +4,10 @@ namespace App\Console\Commands; -use App\Enums\PostPlatform\Status; +use App\Enums\Post\PublishStatus; use App\Enums\SocialAccount\Platform; use App\Jobs\ReconcileGoogleBusinessPost; -use App\Models\PostPlatform; +use App\Models\Post; use Illuminate\Console\Command; class ReconcileGoogleBusinessPosts extends Command @@ -17,24 +17,23 @@ class ReconcileGoogleBusinessPosts extends Command protected $description = 'Settle Google Business Profile posts still awaiting review'; /** - * Google clears review in minutes, so a target checked moments ago has + * Google clears review in minutes, so a post checked moments ago has * nothing new to say. Keeps a five-minute schedule from re-polling every - * pending target on every tick. + * pending post on every tick. */ private const RECHECK_AFTER_MINUTES = 5; public function handle(): int { - PostPlatform::query() - ->enabled() + Post::query() ->where('platform', Platform::GoogleBusiness) - ->where('status', Status::PendingReview) + ->where('publish_status', PublishStatus::PendingReview) ->whereNotNull('platform_post_id') ->where(function ($query): void { $query->whereNull('last_reconciled_at') ->orWhere('last_reconciled_at', '<=', now()->subMinutes(self::RECHECK_AFTER_MINUTES)); }) - ->each(fn (PostPlatform $postPlatform) => ReconcileGoogleBusinessPost::dispatch($postPlatform)); + ->each(fn (Post $post) => ReconcileGoogleBusinessPost::dispatch($post)); return self::SUCCESS; } diff --git a/app/Console/Commands/RecoverStuckPosts.php b/app/Console/Commands/RecoverStuckPosts.php index 1bd7722ad9..e628287349 100644 --- a/app/Console/Commands/RecoverStuckPosts.php +++ b/app/Console/Commands/RecoverStuckPosts.php @@ -5,15 +5,12 @@ namespace App\Console\Commands; use App\Actions\Post\FinalizePostPublication; +use App\Enums\Post\PublishStatus; use App\Enums\Post\Status as PostStatus; -use App\Enums\PostPlatform\Status as PlatformStatus; -use App\Enums\SocialAccount\Platform; -use App\Events\PostPlatformStatusUpdated; +use App\Events\PostStatusUpdated; use App\Exceptions\Social\ErrorCategory; use App\Jobs\ReconcileGoogleBusinessPost; use App\Models\Post; -use App\Models\PostPlatform; -use App\Support\Social\AbandonGoogleBusinessReview; use App\Support\Social\GoogleBusinessDerivativeCleaner; use App\Support\Social\TikTokPhotoDerivativeCleaner; use Illuminate\Console\Command; @@ -24,6 +21,8 @@ class RecoverStuckPosts extends Command protected $description = 'Recover posts stuck in publishing status for more than 1 hour'; + private const array IN_FLIGHT = [PublishStatus::Publishing, PublishStatus::Pending, PublishStatus::Retrying]; + public function __construct( private readonly TikTokPhotoDerivativeCleaner $tiktokPhotoDerivativeCleaner, private readonly GoogleBusinessDerivativeCleaner $googleBusinessDerivativeCleaner, @@ -37,110 +36,67 @@ public function handle(): void ->where('status', PostStatus::Publishing) ->where('updated_at', '<=', now()->subHour()) ->each(function (Post $post): void { - $stalePlatforms = $post->postPlatforms() - ->enabled() - ->whereIn('status', [PlatformStatus::Publishing, PlatformStatus::Pending, PlatformStatus::Retrying]) - ->whereNull('retry_at') - ->where('updated_at', '<=', now()->subHour()) - ->get(); - - $stalePlatforms->each(function (PostPlatform $postPlatform): void { - $this->tiktokPhotoDerivativeCleaner->cleanupUnlessPublishInFlight( - $postPlatform->error_context, - $postPlatform->id, - ); - $this->googleBusinessDerivativeCleaner->cleanup($postPlatform->id); - - $postPlatform->update([ - 'status' => PlatformStatus::Failed, + $stale = in_array($post->publish_status, self::IN_FLIGHT, true) + && $post->retry_at === null + && ($post->publication_updated_at === null || $post->publication_updated_at->lessThanOrEqualTo(now()->subHour())); + + if ($stale) { + $this->tiktokPhotoDerivativeCleaner->cleanupUnlessPublishInFlight($post->error_context, $post->id); + $this->googleBusinessDerivativeCleaner->cleanup($post); + + $post->writePublication([ + 'publish_status' => PublishStatus::Failed, 'error_message' => __('posts.errors.publishing_timed_out'), 'error_context' => [ - ...($postPlatform->error_context ?? []), + ...($post->error_context ?? []), 'category' => ErrorCategory::Timeout->value, 'failed_at' => now()->toIso8601String(), ], ]); - }); - - $this->pruneDisabledGoogleBusinessDerivatives($post); - $this->failExpiredReviews($post); - - // Delayed platform-unavailable retries keep the platform Retrying with a - // fresh updated_at — do not finalize the post while that work is still live. - $stillActive = $post->postPlatforms() - ->enabled() - ->whereIn('status', [ - PlatformStatus::Publishing, - PlatformStatus::Pending, - PlatformStatus::Retrying, - PlatformStatus::PendingReview, - ]) - ->exists(); - - if ($stillActive) { - return; } - app(FinalizePostPublication::class)->handle($post); - - $stalePlatforms->each(fn (PostPlatform $postPlatform) => PostPlatformStatusUpdated::dispatch($postPlatform->fresh())); - }); - } - - /** - * A switched-off GBP target is skipped by reconcile and the 24h ceiling. - * Abandon the review so the parent can settle, and prune the JPEG. - */ - private function pruneDisabledGoogleBusinessDerivatives(Post $post): void - { - $post->postPlatforms() - ->disabled() - ->where('platform', Platform::GoogleBusiness) - ->get() - ->each(function (PostPlatform $postPlatform): void { - if ($postPlatform->status === PlatformStatus::PendingReview) { - AbandonGoogleBusinessReview::execute( - $postPlatform, - __('posts.errors.target_disabled'), - ['category' => 'target_disabled'], - ); + $this->failExpiredReview($post); + // A delayed platform-unavailable retry keeps the post Retrying + // with a fresh clock: do not finalize while that work is live. + if (in_array($post->publish_status, [...self::IN_FLIGHT, PublishStatus::PendingReview], true)) { return; } - $this->googleBusinessDerivativeCleaner->cleanup($postPlatform->id); + app(FinalizePostPublication::class)->handle($post); + + if ($stale) { + PostStatusUpdated::dispatch($post->fresh()); + } }); } /** * PendingReview is supposed to last up to Google's review ceiling, timed * from submitted_at only. A scheduled draft can be days old before it - * enters review — created_at must not trip the ceiling. Rows without - * submitted_at stay in review until reconcile or a later recover after - * markAsPendingReview writes the clock. + * enters review, so created_at must not trip the ceiling. A post without + * submitted_at stays in review until reconcile or a later recover after + * markPublicationPendingReview writes the clock. */ - private function failExpiredReviews(Post $post): void + private function failExpiredReview(Post $post): void { $cutoff = now()->subHours(ReconcileGoogleBusinessPost::REVIEW_CEILING_HOURS); - $post->postPlatforms() - ->enabled() - ->where('status', PlatformStatus::PendingReview) - ->where('submitted_at', '<=', $cutoff) - ->get() - ->each(function (PostPlatform $postPlatform): void { - $postPlatform->markAsRejected( - (string) $postPlatform->platform_post_id, - $postPlatform->platform_url, - __('posts.errors.review_unconfirmed'), - [ - ...($postPlatform->error_context ?? []), - 'category' => 'review_unconfirmed', - 'failed_at' => now()->toIso8601String(), - ], - ); - $this->googleBusinessDerivativeCleaner->cleanup($postPlatform->id); - PostPlatformStatusUpdated::dispatch($postPlatform->fresh()); - }); + if ($post->publish_status !== PublishStatus::PendingReview || $post->submitted_at === null || $post->submitted_at->greaterThan($cutoff)) { + return; + } + + $post->markPublicationRejected( + (string) $post->platform_post_id, + $post->platform_url, + __('posts.errors.review_unconfirmed'), + [ + ...($post->error_context ?? []), + 'category' => 'review_unconfirmed', + 'failed_at' => now()->toIso8601String(), + ], + ); + $this->googleBusinessDerivativeCleaner->cleanup($post); + PostStatusUpdated::dispatch($post->fresh()); } } diff --git a/app/Console/Commands/RetryFailedPost.php b/app/Console/Commands/RetryFailedPost.php index ffcdbcc4a0..76db9ef6d3 100644 --- a/app/Console/Commands/RetryFailedPost.php +++ b/app/Console/Commands/RetryFailedPost.php @@ -4,28 +4,25 @@ namespace App\Console\Commands; +use App\Enums\Post\PublishStatus; use App\Enums\Post\Status as PostStatus; -use App\Enums\PostPlatform\Status as PlatformStatus; use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Exceptions\Social\ErrorCategory; use App\Jobs\PublishToSocialPlatform; use App\Models\Post; -use App\Models\PostPlatform; use App\Support\Social\PublishCheckpoint; use App\Support\Social\ThreadProgress; use App\Support\Social\TikTokPhotoDerivativeCleaner; use Illuminate\Console\Command; -use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; class RetryFailedPost extends Command { protected $signature = 'posts:retry - {post : ID of the post whose failed platforms should be retried}'; + {post : ID of the failed post to retry}'; - protected $description = 'Retry failed platforms, resuming in-flight remote publishes when a checkpoint exists'; + protected $description = 'Retry a failed post, resuming an in-flight remote publish when a checkpoint exists'; public function __construct( private readonly TikTokPhotoDerivativeCleaner $tiktokPhotoDerivativeCleaner, @@ -35,7 +32,7 @@ public function __construct( public function handle(): int { - $post = Post::query()->find((string) $this->argument('post')); + $post = Post::query()->with('socialAccount')->find((string) $this->argument('post')); if (! $post) { $this->error('Post not found.'); @@ -44,125 +41,82 @@ public function handle(): int } if (! $this->isRetryable($post)) { - $this->error('Only failed or partially published posts can be retried.'); - - return self::FAILURE; - } - - $failedPlatforms = $this->failedPlatforms($post); - - if ($failedPlatforms->isEmpty()) { - $this->warn('No failed enabled platforms matched this post.'); + $this->error('Only failed posts with a channel can be retried.'); return self::FAILURE; } $this->table( - ['Post platform ID', 'Platform', 'Account', 'Last error', 'Mode'], - $failedPlatforms->map(fn (PostPlatform $postPlatform): array => [ - $postPlatform->id, - $postPlatform->platform->value, - $postPlatform->display_username ?? '—', - $postPlatform->error_message ?? '—', - $this->resumableContext($postPlatform->error_context) === null ? 'New' : 'Resume', - ])->all(), + ['Post ID', 'Platform', 'Account', 'Last error', 'Mode'], + [[ + $post->id, + $post->platform->value, + $post->display_username ?? '—', + $post->error_message ?? '—', + $this->resumableContext($post->error_context) === null ? 'New' : 'Resume', + ]], ); - if (! $this->confirm('Queue publish attempts for these failed platforms?')) { - + if (! $this->confirm('Queue a publish attempt for this post?')) { return self::SUCCESS; } - $retryEntries = $this->prepareRetryEntries($post); + $originalContext = $this->prepareRetry($post); - if ($retryEntries === []) { + if ($originalContext === false) { $this->warn('The post changed while the command was running; nothing was retried.'); return self::FAILURE; } - foreach ($retryEntries as $entry) { - if ($entry['platform'] === SocialPlatform::TikTok && PublishCheckpoint::tiktokPublishId($entry['error_context']) === null) { - $this->tiktokPhotoDerivativeCleaner->cleanup($entry['original_error_context'], $entry['id']); - } + $post->refresh(); - $postPlatform = PostPlatform::query()->findOrFail($entry['id']); - PublishToSocialPlatform::dispatch($postPlatform); + if ($post->platform === SocialPlatform::TikTok && PublishCheckpoint::tiktokPublishId($post->error_context) === null) { + $this->tiktokPhotoDerivativeCleaner->cleanup($originalContext, $post->id); } - Log::info('Failed post platforms queued for manual retry', [ - 'post_id' => $post->id, - 'post_platform_ids' => array_column($retryEntries, 'id'), - ]); + PublishToSocialPlatform::dispatch($post); + + Log::info('Failed post queued for manual retry', ['post_id' => $post->id]); return self::SUCCESS; } private function isRetryable(Post $post): bool { - return in_array($post->status, [PostStatus::Failed, PostStatus::PartiallyPublished], true); + return $post->status === PostStatus::Failed + && $post->publish_status === PublishStatus::Failed + && $post->hasChannel(); } /** - * @return Collection - */ - private function failedPlatforms(Post $post, bool $lockForUpdate = false): Collection - { - return PostPlatform::query() - ->with('socialAccount') - ->where('post_id', $post->id) - ->enabled() - ->where('status', PlatformStatus::Failed) - ->when($lockForUpdate, fn (Builder $query) => $query->lockForUpdate()) - ->get(); - } - - /** - * @return list|null, - * original_error_context: array|null - * }> + * Returns the error context the failure left, or false when the post is + * no longer retryable. + * + * @return array|null|false */ - private function prepareRetryEntries(Post $post): array + private function prepareRetry(Post $post): array|null|false { - return DB::transaction(function () use ($post): array { - $lockedPost = Post::query()->lockForUpdate()->find($post->id); + return DB::transaction(function () use ($post): array|null|false { + $locked = Post::query()->lockForUpdate()->find($post->id); - if (! $lockedPost || ! $this->isRetryable($lockedPost)) { - return []; + if (! $locked || ! $this->isRetryable($locked)) { + return false; } - $platforms = $this->failedPlatforms($lockedPost, lockForUpdate: true); - - if ($platforms->isEmpty()) { - return []; - } + $originalContext = $locked->error_context; - $entries = []; - - foreach ($platforms as $postPlatform) { - $nextContext = $this->resumableContext($postPlatform->error_context); - $entries[] = [ - 'id' => $postPlatform->id, - 'platform' => $postPlatform->platform, - 'error_context' => $nextContext, - 'original_error_context' => $postPlatform->error_context, - ]; - - $postPlatform->update([ - 'status' => PlatformStatus::Pending, - 'platform_post_id' => null, - 'platform_url' => null, - 'error_message' => null, - 'error_context' => $nextContext, - 'published_at' => null, - ]); - } - $lockedPost->update(['status' => PostStatus::Publishing]); + $locked->writePublication([ + 'publish_status' => PublishStatus::Pending, + 'platform_post_id' => null, + 'platform_url' => null, + 'error_message' => null, + 'error_context' => $this->resumableContext($originalContext), + 'published_at' => null, + ]); + $locked->update(['status' => PostStatus::Publishing]); - return $entries; + return $originalContext; }); } diff --git a/app/Console/Commands/Scripts/AuditLegacyPosts.php b/app/Console/Commands/Scripts/AuditLegacyPosts.php deleted file mode 100644 index 20b0d0b997..0000000000 --- a/app/Console/Commands/Scripts/AuditLegacyPosts.php +++ /dev/null @@ -1,53 +0,0 @@ -whereIn('status', [Status::Draft, Status::Scheduled]); - $multiTarget = (clone $active)->whereHas('postPlatforms', fn ($query) => $query->enabled(), '>', 1)->count(); - $zeroTargetDrafts = Post::query()->draft() - ->whereDoesntHave('postPlatforms', fn ($query) => $query->enabled())->count(); - $zeroTargetScheduled = Post::query()->scheduled() - ->whereDoesntHave('postPlatforms', fn ($query) => $query->enabled())->count(); - $unavailableScheduled = Post::query()->scheduled() - ->whereHas('postPlatforms', fn ($query) => $query->enabled()->where(function ($target): void { - $target->whereNull('social_account_id') - ->orWhereDoesntHave('socialAccount'); - }))->count(); - $settledAggregates = Post::query() - ->whereIn('status', [Status::Published, Status::PartiallyPublished, Status::Failed]) - ->whereHas('postPlatforms', fn ($query) => $query->enabled(), '>', 1)->count(); - $inFlightAggregates = Post::query()->where('status', Status::Publishing) - ->whereHas('postPlatforms', fn ($query) => $query->enabled(), '>', 1)->count(); - - $this->table(['Category', 'Posts'], [ - ['Editable with multiple enabled targets', $multiTarget], - ['Draft with no enabled target (recoverable)', $zeroTargetDrafts], - ['Scheduled with no enabled target (blocker)', $zeroTargetScheduled], - ['Scheduled with an unavailable target (blocker)', $unavailableScheduled], - ['Settled with multiple enabled targets (split, each post takes its target status)', $settledAggregates], - ['In-flight aggregate (left running, not split)', $inFlightAggregates], - ]); - - if ($this->option('strict') && ($multiTarget > 0 || $zeroTargetScheduled > 0 || $unavailableScheduled > 0)) { - $this->error('Legacy post audit found unresolved blockers.'); - - return self::FAILURE; - } - - return self::SUCCESS; - } -} diff --git a/app/Console/Commands/Scripts/BackfillExistingAnalytics.php b/app/Console/Commands/Scripts/BackfillExistingAnalytics.php deleted file mode 100644 index 17b64e1221..0000000000 --- a/app/Console/Commands/Scripts/BackfillExistingAnalytics.php +++ /dev/null @@ -1,139 +0,0 @@ -platforms(); - - if ($platforms === null) { - return self::FAILURE; - } - - $workspaceId = $this->option('workspace'); - $includeUnsubscribed = (bool) $this->option('include-unsubscribed'); - $batchSize = max(1, (int) $this->option('chunk')); - $delay = max(0, (int) $this->option('delay')); - $staleBefore = CarbonImmutable::now('UTC')->subHours(2); - $date = CarbonImmutable::now('UTC')->toDateString(); - $orphaned = 0; - $linked = 0; - $accounts = 0; - $imported = 0; - - Workspace::query() - ->with('account.subscriptions') - ->when($workspaceId, fn (Builder $query): Builder => $query->whereKey($workspaceId)) - ->reorder() - ->lazyById(100) - ->filter(fn (Workspace $workspace): bool => $includeUnsubscribed || $workspace->account->subscribed(Account::SUBSCRIPTION_NAME)) - ->chunk(100) - ->each(function ($workspaces) use ($batchSize, $date, $delay, $platforms, $staleBefore, &$accounts, &$imported, &$linked, &$orphaned): void { - $ids = $workspaces->pluck('id')->all(); - $inWorkspaces = fn (Builder $post): Builder => $post->whereIn('workspace_id', $ids); - $onPlatforms = fn (Builder $query, string $column): Builder => $query->when($platforms !== [], fn (Builder $scoped): Builder => $scoped->whereIn($column, $platforms)); - - $onPlatforms(PostPlatform::query(), 'post_platforms.platform') - ->published() - ->includedInAnalytics() - ->whereNotNull('social_account_id') - ->whereDoesntHave('analyticsPublication') - ->whereHas('post', $inWorkspaces) - ->lazyById(100) - ->chunk(100) - ->each(function ($chunk) use (&$linked): void { - BackfillTryPostPublications::dispatch($chunk->pluck('id')->map(fn ($id): string => (string) $id)->values()->all()); - $linked += $chunk->count(); - }); - - $orphaned += $onPlatforms(PostPlatform::query(), 'post_platforms.platform') - ->published() - ->includedInAnalytics() - ->whereNull('social_account_id') - ->whereHas('post', $inWorkspaces) - ->count(); - - $onPlatforms(SocialAccount::query(), 'platform') - ->connected() - ->includedInAnalytics() - ->whereIn('workspace_id', $ids) - ->where(function (Builder $query) use ($staleBefore): void { - $query->whereDoesntHave('analyticsSyncStates', fn (Builder $states): Builder => $states->forCollector(SyncCollector::PublicationBackfill)) - ->orWhereHas('analyticsSyncStates', fn (Builder $states): Builder => $states - ->forCollector(SyncCollector::PublicationBackfill) - ->where(function (Builder $state): void { - $state->whereIn('status', [SyncStatus::Pending, SyncStatus::Running]) - ->orWhere(fn (Builder $failed): Builder => $failed - ->where('status', SyncStatus::Failed) - ->where('last_error_category', 'queue_failed')); - }) - ->where('updated_at', '<', $staleBefore)); - }) - ->with(['analyticsSyncStates' => fn ($query) => $query->forCollector(SyncCollector::PublicationBackfill)]) - ->lazyById(100) - ->each(function (SocialAccount $account) use ($batchSize, $date, $delay, &$accounts): void { - $wait = intdiv($accounts, $batchSize) * $delay; - - if ($account->analyticsSyncStates->isEmpty()) { - CollectAccountDailySnapshot::dispatch($account->id, $date)->delay($wait); - } - - BootstrapAccountAnalytics::dispatch($account->id)->delay($wait); - $accounts++; - }); - - $imported += Post::query() - ->imported() - ->whereIn('workspace_id', $ids) - ->when($platforms !== [], fn (Builder $query): Builder => $query->whereHas('postPlatforms', fn (Builder $targets): Builder => $targets->whereIn('platform', $platforms))) - ->count(); - }); - - $this->info("accounts_dispatched={$accounts}"); - $this->info("publications_to_link={$linked}"); - $this->info("posts_imported={$imported}"); - $this->info("historical_identity_unrecoverable={$orphaned}"); - - return self::SUCCESS; - } - - /** - * @return list|null - */ - private function platforms(): ?array - { - $values = array_values(array_filter(array_map('trim', explode(',', (string) $this->option('platforms'))))); - $unknown = array_values(array_filter($values, fn (string $value): bool => Platform::tryFrom($value) === null)); - - if ($unknown !== []) { - $this->error('Unknown platforms: '.implode(', ', $unknown)); - - return null; - } - - return $values; - } -} diff --git a/app/Console/Commands/Scripts/BakeAspectRatioCropsCommand.php b/app/Console/Commands/Scripts/BakeAspectRatioCropsCommand.php deleted file mode 100644 index 1608ac32c9..0000000000 --- a/app/Console/Commands/Scripts/BakeAspectRatioCropsCommand.php +++ /dev/null @@ -1,41 +0,0 @@ -info("Baked {$result['baked_images']} cropped image(s) into {$result['baked_posts']} post(s); dropped the aspect ratio from {$result['stripped_targets']} other target(s)."); - - if ($result['skipped_posts'] !== []) { - $ids = implode(', ', $result['skipped_posts']); - $this->warn('Left '.count($result['skipped_posts'])." post(s) with more than one enabled target uncropped for now (their media is shared; they keep their aspect ratio until a run after posts:split-legacy-active): {$ids}"); - } - - if ($result['unreadable_images'] !== []) { - $ids = implode(', ', $result['unreadable_images']); - $this->warn('Left '.count($result['unreadable_images'])." image(s) whose file could not be read uncropped (post:media): {$ids}"); - } - - if ($result['failed_posts'] !== []) { - $ids = implode(', ', $result['failed_posts']); - $this->error('Failed to bake '.count($result['failed_posts'])." post(s), which keep their aspect ratio; run the command again: {$ids}"); - - return self::FAILURE; - } - - return self::SUCCESS; - } -} diff --git a/app/Console/Commands/Scripts/PurgeOrphanedPostsCommand.php b/app/Console/Commands/Scripts/PurgeOrphanedPostsCommand.php deleted file mode 100644 index 3d72ff78a2..0000000000 --- a/app/Console/Commands/Scripts/PurgeOrphanedPostsCommand.php +++ /dev/null @@ -1,33 +0,0 @@ -option('workspace'); - - if (filled($workspaceId) && ! Str::isUuid($workspaceId)) { - $this->error('The --workspace option must be a workspace UUID.'); - - return self::FAILURE; - } - - $result = DeleteChannelPosts::orphaned(filled($workspaceId) ? $workspaceId : null); - - $this->info("{$result['deleted_posts']} orphaned post(s) deleted, {$result['detached_targets']} orphaned target(s) removed from posts on other channels."); - - return self::SUCCESS; - } -} diff --git a/app/Console/Commands/Scripts/ReleaseTryPost2.php b/app/Console/Commands/Scripts/ReleaseTryPost2.php deleted file mode 100644 index b044accfee..0000000000 --- a/app/Console/Commands/Scripts/ReleaseTryPost2.php +++ /dev/null @@ -1,506 +0,0 @@ - */ - private array $summary = []; - - /** @var list */ - private array $failedWorkspaces = []; - - public function handle(Migrator $migrator): int - { - $pending = $this->pendingMigrations($migrator); - - if ($pending !== []) { - $count = count($pending); - $this->error("{$count} pending migration(s). Run `php artisan migrate --force` (part of the deploy) first:"); - $this->line(collect($pending)->map(fn (string $name): string => " {$name}")->implode(PHP_EOL)); - - return self::FAILURE; - } - - $dryRun = (bool) $this->option('dry-run'); - - if (! $dryRun && ! $this->confirmToProceed()) { - return self::FAILURE; - } - - $this->info('Baseline media audit (database and bucket listing only, read-only)...'); - $before = $this->snapshot(); - - $chunk = max(1, (int) $this->option('backfill-chunk')); - $delay = max(0, (int) $this->option('backfill-delay')); - $unsubscribed = $this->option('include-unsubscribed') ? ' --include-unsubscribed' : ''; - $instagram = Platform::Instagram->value; - $instagramFacebook = Platform::InstagramFacebook->value; - - $this->runReleaseStep('Purge posts of disconnected channels', 'posts:purge-orphaned', preview: function (): string { - $plan = DeleteChannelPosts::orphanedPlan(); - - return "would delete {$plan['deleted_posts']} post(s) and detach {$plan['detached_targets']} target(s)"; - }); - $this->runReleaseStep('Split legacy multi-target posts', 'posts:split-legacy-active', preview: function (): string { - $this->call('posts:audit-legacy'); - - return 'splits the "Editable" and "Settled" multiple-target posts above; in-flight ones stay'; - }); - $this->runReleaseStep('Bake legacy aspect ratio crops', 'posts:bake-aspect-ratio-crops', preview: function (): string { - $pending = BakeLegacyAspectRatioCrops::pending(); - - return "would bake the aspect ratio crop of {$pending} scheduled or in-flight post(s)"; - }); - - if ($dryRun) { - $this->runReleaseStep('Media library adoption', 'media:adopt-library --dry-run', readOnly: true); - } else { - $this->adoptLibrary(); - } - - $this->channelDefaults(); - $this->backfill("analytics:backfill-existing --chunk={$chunk} --delay={$delay}{$unsubscribed}"); - $this->runReleaseStep('Instagram stories discovery (queued)', "analytics:dispatch-publication-discovery --platform={$instagram} --platform={$instagramFacebook}"); - $unexpected = $this->audit($before); - - $this->newLine(); - $this->table(['Step', 'Status', 'Result'], $this->summary); - - if ($dryRun) { - $this->info('Dry run: nothing was changed. The audit shows the state before the release.'); - - return self::SUCCESS; - } - - $failedSteps = collect($this->summary)->filter(fn (array $row): bool => $row[1] === 'failed')->count(); - - if ($failedSteps > 0 || $unexpected > 0) { - $this->error("Release finished with problems: {$failedSteps} failed step(s), {$unexpected} unexpected audit finding(s). Fix them and run the command again; finished steps are no-ops."); - - return self::FAILURE; - } - - $this->info('Release steps done. Watch the analytics and media-imports queues in Horizon until they drain.'); - - return self::SUCCESS; - } - - /** - * Queues one `AdoptWorkspaceLibraryJob` per workspace with library media - * (its own queue, so Horizon adopts them in parallel), then waits until - * every library is adopted, every remaining job has stopped, or - * `--adoption-timeout` passes. A workspace still adopting at the timeout - * keeps its job (it retries on its own); one whose job stopped with - * library media left fails the step. - */ - private function adoptLibrary(): void - { - $title = 'Media library adoption (queued)'; - $this->heading($title); - - $workspaces = Workspace::query() - ->whereIn('id', Media::query()->where('collection', Media::LIBRARY_COLLECTION)->whereNotNull('workspace_id')->select('workspace_id')) - ->orderBy('id') - ->get(); - - if ($workspaces->isEmpty()) { - $this->line(' No library media to adopt.'); - $this->summary[] = [$title, 'ok', 'no library media']; - - return; - } - - $total = $workspaces->count(); - - if (! $this->queueAdoption($total)) { - $this->failedWorkspaces = $workspaces->modelKeys(); - $this->summary[] = [$title, 'failed', "{$total} workspace(s) could not be queued, run media:adopt-library --force"]; - - return; - } - - $timeout = max(0, (int) $this->option('adoption-timeout')); - $started = now(); - $deadline = $started->addSeconds($timeout); - - while (true) { - $progress = $this->adoptionProgress($workspaces); - $done = $total - count($progress['running']) - count($progress['stopped']); - $stopped = count($progress['stopped']); - $elapsed = (int) $started->diffInSeconds(now()); - $this->line(" {$done}/{$total} workspace(s) done, {$progress['rows']} library row(s) left, {$stopped} stopped ({$elapsed}s)"); - - if ($progress['running'] === [] || now()->greaterThanOrEqualTo($deadline)) { - break; - } - - Sleep::for(self::ADOPTION_POLL_SECONDS)->seconds(); - } - - $running = count($progress['running']); - $result = "{$done} of {$total} workspace(s) adopted in {$elapsed}s"; - - if ($stopped > 0) { - $this->failedWorkspaces = $progress['stopped']; - $ids = implode(', ', $progress['stopped']); - $this->error(" {$stopped} workspace(s) stopped with library media left: {$ids}. See the log, fix the cause and run media:adopt-library --force."); - } - - if ($running > 0) { - $this->warn(" {$running} workspace(s) still adopting after {$elapsed}s; their jobs keep going on the media-adoption queue."); - } - - $this->summary[] = match (true) { - $stopped > 0 => [$title, 'failed', "{$result}; {$stopped} workspace(s) stopped with library media left: ".implode(', ', $progress['stopped'])], - $running > 0 => [$title, 'queued', "{$result}; {$running} workspace(s) still adopting on the queue"], - default => [$title, 'ok', $result], - }; - } - - /** - * A workspace is done once every library row left is one another - * workspace still uses (the foreign references `media:adopt-library` - * cached); otherwise it is running while its job's unique lock is held, - * and stopped once the job ended without finishing. - * - * @param Collection $workspaces - * @return array{rows: int, running: list, stopped: list} - */ - private function adoptionProgress(Collection $workspaces): array - { - $kept = Cache::get(AdoptWorkspaceLibrary::FOREIGN_REFERENCES_CACHE_KEY, []); - $left = Media::query() - ->where('collection', Media::LIBRARY_COLLECTION) - ->whereIn('workspace_id', $workspaces->modelKeys()) - ->toBase() - ->get(['id', 'workspace_id']) - ->reject(fn (object $row): bool => isset($kept[$row->workspace_id][$row->id])) - ->groupBy('workspace_id'); - $progress = ['rows' => $left->flatten(1)->count(), 'running' => [], 'stopped' => []]; - - foreach ($workspaces->whereIn('id', $left->keys()->all()) as $workspace) { - $lock = Cache::lock(UniqueLock::getKey(new AdoptWorkspaceLibraryJob($workspace)), 1); - $held = ! $lock->get(); - - if (! $held) { - $lock->release(); - } - - $progress[$held ? 'running' : 'stopped'][] = $workspace->id; - } - - return $progress; - } - - /** - * Dispatches the adoption jobs through `media:adopt-library` (which - * skips workspaces whose job is still queued); false when that fails. - */ - private function queueAdoption(int $count): bool - { - $this->line(" Queueing AdoptWorkspaceLibraryJob for {$count} workspace(s) on the media-adoption queue."); - - try { - $this->call('media:adopt-library', ['--force' => true]); - - return true; - } catch (Throwable $exception) { - report($exception); - $this->error(" Could not queue AdoptWorkspaceLibraryJob: {$exception->getMessage()}"); - - return false; - } - } - - /** - * Channels connected before posting schedules existed get what a new - * channel gets (owner time zone, goal 3, the network's recommended slots); - * a channel that already has a schedule is never touched. - */ - private function channelDefaults(): void - { - $title = 'Channel posting defaults'; - $this->heading($title); - - if ($this->option('dry-run')) { - $pending = ApplyChannelDefaults::pending(); - $this->line(" Would set up {$pending} channel(s) without a posting schedule."); - $this->summary[] = [$title, 'dry run', "would set up {$pending} channel(s)"]; - - return; - } - - try { - $count = ApplyChannelDefaults::backfill(); - } catch (Throwable $exception) { - report($exception); - $this->error(" Failed: {$exception->getMessage()}"); - $this->summary[] = [$title, 'failed', $exception->getMessage()]; - - return; - } - - $this->line(" Set up {$count} channel(s) without a posting schedule."); - $this->summary[] = [$title, 'ok', "{$count} channel(s) set up"]; - } - - /** - * Dispatched once per install: its jobs may wait hours behind - * `--backfill-delay`, and a second dispatch before they start would read - * every account twice (X bills per read). - */ - private function backfill(string $command): void - { - $title = 'Analytics backfill and external posts import (queued)'; - $dispatchedAt = Cache::store('database')->get(self::BACKFILL_DISPATCHED_KEY); - - if ($dispatchedAt !== null && ! $this->option('rerun-backfill')) { - $this->heading($title); - $this->line(" Skipped: already dispatched at {$dispatchedAt}. Pass --rerun-backfill to dispatch it again."); - $this->summary[] = [$title, 'skipped', "already dispatched at {$dispatchedAt}"]; - - return; - } - - if ($this->runReleaseStep($title, $command) && ! $this->option('dry-run')) { - Cache::store('database')->forever(self::BACKFILL_DISPATCHED_KEY, now()->toIso8601String()); - } - } - - /** - * The state the final audit is compared with: the audit without its - * per-row storage requests (only the final audit checks every file, after - * the data steps), and the rows that existed before the release. - * - * @return array{report: array>>, ids: array, paths: array, dangling: array} - */ - private function snapshot(): array - { - $report = AuditMedia::execute(checkMissingFiles: false); - $ids = []; - $paths = []; - $dangling = []; - - Media::query()->toBase()->select(['id', 'path', 'collection'])->chunkById(AuditMedia::CHUNK, function ($rows) use (&$ids, &$paths, &$dangling): void { - foreach ($rows as $row) { - $ids[$row->id] = true; - $paths[$row->path] = true; - - if ($row->collection === Media::LIBRARY_COLLECTION) { - $dangling[$row->id] = true; - } - } - }); - - foreach ($report['json_drift'] as $finding) { - if ($finding['problem'] === 'json_without_row') { - $dangling[$finding['media_id']] = true; - } - } - - return ['report' => $report, 'ids' => $ids, 'paths' => $paths, 'dangling' => $dangling]; - } - - /** - * Compares the final media audit with the snapshot. A finding is expected - * when it was already there; when an item points at a library row the - * adoption kept (another workspace uses it, or it was left to the queued - * job); when an item points at a row that is gone but was a library row or - * already dangling before the release; when a file without a row belonged - * to a row before the release (its deletion is queued); or when a row from - * before the release has no file (the snapshot does not check files). - * Anything else, such as a referenced row the release deleted, is - * unexpected. - * - * @param array{report: array>>, ids: array, paths: array, dangling: array} $before - */ - private function audit(array $before): int - { - $title = 'Media audit (read-only)'; - $this->heading($title); - - $started = microtime(true); - $report = $this->option('dry-run') ? $before['report'] : AuditMedia::execute(); - $seconds = number_format(microtime(true) - $started, 1); - $fingerprint = fn (array $finding): string => json_encode(collect($finding)->sortKeys()->all()); - $known = collect($before['report'])->map(fn (array $findings): array => array_flip(array_map($fingerprint, $findings)))->all(); - $collections = collect($report['json_drift'] ?? []) - ->pluck('media_id') - ->filter(fn (string $id): bool => Str::isUuid($id)) - ->chunk(1000) - ->flatMap(fn ($ids) => Media::query()->whereIn('id', $ids->all())->pluck('collection', 'id')) - ->all(); - - $isExpected = fn (string $check, array $finding): bool => isset($known[$check][$fingerprint($finding)]) - || ($check === 'json_drift' && $finding['problem'] === 'json_without_row' && match ($collections[$finding['media_id']] ?? null) { - null => isset($before['dangling'][$finding['media_id']]), - Media::LIBRARY_COLLECTION => true, - default => false, - }) - || ($check === 'orphaned_files' && isset($before['paths'][$finding['path']])) - || ($check === 'missing_files' && isset($before['ids'][$finding['media_id']])); - - $rows = []; - $unexpected = []; - - foreach ($report as $check => $findings) { - $surprises = array_values(array_filter($findings, fn (array $finding): bool => ! $isExpected($check, $finding))); - $rows[] = [$check, count($findings) - count($surprises), count($surprises)]; - - foreach ($surprises as $finding) { - $unexpected[] = "{$check}: ".collect($finding)->map(fn (string $value, string $key): string => "{$key}={$value}")->implode(' '); - } - } - - $this->table(['Check', 'Expected', 'Unexpected'], $rows); - - foreach ($unexpected as $line) { - $this->line(" unexpected {$line}"); - } - - $expected = collect($rows)->sum(1); - $count = count($unexpected); - $status = match (true) { - $this->option('dry-run') => 'dry run', - $count === 0 => 'ok', - default => 'findings', - }; - $this->summary[] = [$title, $status, "{$expected} expected, {$count} unexpected in {$seconds}s"]; - - return $count; - } - - /** - * Runs one existing command, echoing its output. In a dry run a write - * step only prints what it would do (and its preview); a read-only one - * runs. - * - * @param (callable(): string)|null $preview - */ - private function runReleaseStep(string $title, string $command, bool $readOnly = false, ?callable $preview = null): bool - { - $this->heading($title); - - if ($this->option('dry-run') && ! $readOnly) { - $this->line(" Would run: php artisan {$command}"); - $detail = $preview !== null ? $preview() : "php artisan {$command}"; - $this->line(" {$detail}"); - $this->summary[] = [$title, 'dry run', $detail]; - - return true; - } - - $buffer = new BufferedOutput; - - try { - $code = Artisan::call($command, [], $buffer); - } catch (Throwable $exception) { - report($exception); - $this->output->write($buffer->fetch()); - $this->error(" Failed: {$exception->getMessage()}"); - $this->summary[] = [$title, 'failed', $exception->getMessage()]; - - return false; - } - - $output = $buffer->fetch(); - $this->output->write($output); - - $digest = collect(explode(PHP_EOL, $output)) - ->map(fn (string $line): string => trim($line)) - ->reject(fn (string $line): bool => $line === '' || Str::startsWith($line, ['+', '|'])) - ->implode('; '); - - $status = match (true) { - $code !== self::SUCCESS => 'failed', - $this->option('dry-run') => 'dry run', - default => 'ok', - }; - $this->summary[] = [$title, $status, $digest !== '' ? $digest : 'done']; - - return $code === self::SUCCESS; - } - - private function heading(string $title): void - { - $step = count($this->summary) + 1; - $steps = self::STEPS; - - $this->newLine(); - $this->info("[{$step}/{$steps}] {$title}"); - } - - /** - * @return list - */ - private function pendingMigrations(Migrator $migrator): array - { - if (! $migrator->repositoryExists()) { - return ['migrations table']; - } - - $files = $migrator->getMigrationFiles([database_path('migrations'), ...$migrator->paths()]); - - return array_values(array_diff(array_keys($files), $migrator->getRepository()->getRan())); - } -} diff --git a/app/Console/Commands/Scripts/SplitLegacyActivePosts.php b/app/Console/Commands/Scripts/SplitLegacyActivePosts.php deleted file mode 100644 index a1b8d3ce18..0000000000 --- a/app/Console/Commands/Scripts/SplitLegacyActivePosts.php +++ /dev/null @@ -1,221 +0,0 @@ -where(fn (Builder $query) => $query - ->where(fn (Builder $multiTarget) => $multiTarget - ->whereIn('status', [Status::Draft, Status::Scheduled, Status::Published, Status::PartiallyPublished, Status::Failed]) - ->whereHas('postPlatforms', fn ($targets) => $targets->enabled(), '>', 1)) - ->orWhere('status', Status::PartiallyPublished)) - ->select('id') - ->orderBy('id') - ->chunkById(100, function ($posts) use (&$splitPosts, &$createdPosts, &$unfinishedPosts): void { - foreach ($posts as $candidate) { - try { - $clones = MediaCopyBatch::run(fn (MediaCopyBatch $batch): int => $this->splitPost($candidate->id, $batch)); - } catch (ValidationException) { - $this->warn("Post {$candidate->id} was not split: a media file could not be copied."); - - continue; - } - - if ($clones === self::UNFINISHED) { - $unfinishedPosts++; - - continue; - } - - if ($clones > 0) { - $splitPosts++; - $createdPosts += $clones; - } - } - }); - - $this->info("Split {$splitPosts} original posts and created {$createdPosts} independent posts."); - - if ($unfinishedPosts > 0) { - $this->warn("Left {$unfinishedPosts} settled post(s) with an unfinished target as they are."); - } - - $groupedPosts = 0; - - Post::query() - ->whereNull('post_group_id') - ->has('postPlatforms', '>', 1) - ->select('id') - ->orderBy('id') - ->chunkById(100, function ($posts) use (&$groupedPosts): void { - foreach ($posts as $post) { - $groupedPosts += Post::query() - ->whereKey($post->id) - ->whereNull('post_group_id') - ->toBase() - ->update(['post_group_id' => (string) Str::uuid7()]); - } - }); - - $this->info("Grouped {$groupedPosts} multi-target posts."); - - return self::SUCCESS; - } - - /** - * Each copy keeps every item of the original, in order and as it is - * (items without an id or repeated ones included); only an item pointing - * at a row the original owns (an adopted library or an edit) is swapped - * for the copy's own copy of that row. Library rows and missing files are - * left for the adoption, which copies them per post. - * - * A settled post (published, partially published, failed) is split too: - * each post takes the status of its own target, and the target row moves - * with its permalink, error and analytics link. Disabled targets stay on - * the original as the history of a destination that never ran. A settled - * post with a target still in flight is left as it is (UNFINISHED). - */ - private function splitPost(string $postId, MediaCopyBatch $batch): int - { - $post = Post::query()->lockForUpdate()->findOrFail($postId); - $settled = $post->status->isSettled(); - - if (! $settled && ! in_array($post->status, [Status::Draft, Status::Scheduled], true)) { - return 0; - } - - $targets = $post->postPlatforms()->enabled()->orderBy('id')->lockForUpdate()->get(); - $statuses = $settled ? $targets->map(fn (PostPlatform $target): ?Status => self::postStatusFor($target->status))->values() : collect(); - - if ($statuses->contains(null)) { - return self::UNFINISHED; - } - - if ($settled && $targets->count() === 1) { - $this->settleOriginal($post, $statuses->first(), $targets->first()); - } - - if ($targets->count() <= 1) { - return 0; - } - - if ($post->post_group_id === null) { - $groupId = (string) Str::uuid7(); - Post::query()->whereKey($post->id)->toBase()->update(['post_group_id' => $groupId]); - $post->forceFill(['post_group_id' => $groupId])->syncOriginal(); - } - - $items = array_values($post->media ?? []); - $ownedIds = Media::query() - ->where('post_id', $post->id) - ->whereIn('id', collect($items)->map(fn (mixed $item): mixed => data_get($item, 'id'))->filter(fn (mixed $id): bool => is_string($id) && Str::isUuid($id))->unique()->values()->all()) - ->orderBy('id') - ->pluck('id') - ->all(); - $labelIds = $post->labels()->pluck('workspace_labels.id')->all(); - $notes = $post->notes()->orderBy('id')->get(); - - foreach ($targets->skip(1) as $index => $target) { - $clone = Post::withoutEvents(function () use ($post, $settled, $statuses, $index, $target): Post { - $clone = $post->replicate(); - $clone->status = $settled ? $statuses[$index] : $post->status; - $clone->published_at = $settled ? $target->published_at : $post->published_at; - $clone->created_at = $post->created_at; - $clone->updated_at = $post->updated_at; - $clone->saveQuietly(); - - return $clone; - }); - $this->copyOwnedMedia($clone, $items, $ownedIds, $batch); - $clone->labels()->sync($labelIds); - - foreach ($notes as $note) { - PostNote::withoutEvents(function () use ($note, $clone): void { - $copy = $note->replicate(); - $copy->post_id = $clone->id; - $copy->created_at = $note->created_at; - $copy->updated_at = $note->updated_at; - $copy->saveQuietly(); - }); - } - - PostPlatform::query()->whereKey($target->id)->toBase()->update(['post_id' => $clone->id]); - Post::query()->whereKey($clone->id)->toBase()->update(['updated_at' => $post->getRawOriginal('updated_at')]); - } - - if ($settled) { - $this->settleOriginal($post, $statuses->first(), $targets->first()); - } - - return $targets->count() - 1; - } - - /** - * @param list $items - * @param list $ownedIds - */ - private function copyOwnedMedia(Post $clone, array $items, array $ownedIds, MediaCopyBatch $batch): void - { - if ($ownedIds === []) { - return; - } - - $copies = array_combine($ownedIds, SyncOwnedMedia::execute($clone, array_map(fn (string $id): array => ['id' => $id], $ownedIds), $batch)); - - $clone->forceFill(['media' => array_map(function (mixed $item) use ($copies): mixed { - $copy = is_array($item) ? ($copies[data_get($item, 'id')] ?? null) : null; - - return $copy === null ? $item : [...$item, 'id' => $copy['id'], 'path' => $copy['path'], 'url' => $copy['url']]; - }, $items)])->saveQuietly(); - } - - private static function postStatusFor(PostPlatformStatus $status): ?Status - { - return match ($status) { - PostPlatformStatus::Published => Status::Published, - PostPlatformStatus::Failed, PostPlatformStatus::Rejected => Status::Failed, - default => null, - }; - } - - /** - * The original keeps its first target: its status and publish time come - * from that target. Query-builder writes, so no event fires and - * `updated_at` stays. - */ - private function settleOriginal(Post $post, Status $status, PostPlatform $target): void - { - Post::query()->whereKey($post->id)->toBase()->update([ - 'status' => $status->value, - 'published_at' => $target->getRawOriginal('published_at'), - ]); - } -} diff --git a/app/Dto/Analytics/PublicationFilter.php b/app/Dto/Analytics/PublicationFilter.php index 39a9084bb1..e6dd2ba548 100644 --- a/app/Dto/Analytics/PublicationFilter.php +++ b/app/Dto/Analytics/PublicationFilter.php @@ -14,7 +14,7 @@ /** * Narrows a channel's publications by the labels of their TryPost post and by post type. * - * Expects the publication query aliased as `publication`, left-joined to its post platform as `destination`. + * Expects the publication query aliased as `publication`, left-joined to its post as `destination`. * A publication with a post platform takes that post platform's content type; one without (published * outside TryPost and never imported) is typed through ContentType::fromPublication(), and an unknown * publication type matches no post type. A publication without a post carries no labels, so it only @@ -77,7 +77,7 @@ public function apply(Builder $query): Builder */ private function posts(): EloquentBuilder { - return Post::query()->select('posts.id')->whereColumn('posts.id', 'destination.post_id'); + return Post::query()->select('posts.id')->whereColumn('posts.id', 'destination.id'); } /** diff --git a/app/Enums/PostPlatform/Status.php b/app/Enums/Post/PublishStatus.php similarity index 79% rename from app/Enums/PostPlatform/Status.php rename to app/Enums/Post/PublishStatus.php index 3c67895040..df5b089e8b 100644 --- a/app/Enums/PostPlatform/Status.php +++ b/app/Enums/Post/PublishStatus.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace App\Enums\PostPlatform; +namespace App\Enums\Post; -enum Status: string +enum PublishStatus: string { case Pending = 'pending'; case Publishing = 'publishing'; @@ -14,7 +14,9 @@ enum Status: string case Failed = 'failed'; case Rejected = 'rejected'; - /** Published, failed, or rejected — counts toward settling the parent post. */ + public const self DEFAULT = self::Pending; + + /** Published, failed, or rejected — the post can be settled. */ public function isFinished(): bool { return match ($this) { diff --git a/app/Enums/Post/Status.php b/app/Enums/Post/Status.php index 851de05eed..c4afe00ba6 100644 --- a/app/Enums/Post/Status.php +++ b/app/Enums/Post/Status.php @@ -11,7 +11,6 @@ enum Status: string case Scheduled = 'scheduled'; case Publishing = 'publishing'; case Published = 'published'; - case PartiallyPublished = 'partially_published'; case Failed = 'failed'; public function label(): string @@ -22,7 +21,6 @@ public function label(): string self::Scheduled => __('posts.status.scheduled'), self::Publishing => __('posts.status.publishing'), self::Published => __('posts.status.published'), - self::PartiallyPublished => __('posts.status.partially_published'), self::Failed => __('posts.status.failed'), }; } @@ -35,16 +33,15 @@ public function color(): string self::Scheduled => 'blue', self::Publishing => 'yellow', self::Published => 'green', - self::PartiallyPublished => 'orange', self::Failed => 'red', }; } - /** Published, partially published, or failed — Finalize must not notify again. */ + /** Published or failed — Finalize must not notify again. */ public function isSettled(): bool { return match ($this) { - self::Published, self::PartiallyPublished, self::Failed => true, + self::Published, self::Failed => true, default => false, }; } diff --git a/app/Enums/Webhook/EventType.php b/app/Enums/Webhook/EventType.php index 664b7d57dc..6116b1897b 100644 --- a/app/Enums/Webhook/EventType.php +++ b/app/Enums/Webhook/EventType.php @@ -12,7 +12,6 @@ enum EventType: string case PostScheduled = 'post.scheduled'; case PostUnscheduled = 'post.unscheduled'; case PostPublished = 'post.published'; - case PostPartiallyPublished = 'post.partially_published'; case PostFailed = 'post.failed'; case PostDeleted = 'post.deleted'; @@ -21,7 +20,6 @@ public static function fromPostStatus(PostStatus $status, ?PostStatus $previous return match ($status) { PostStatus::Scheduled => self::PostScheduled, PostStatus::Published => self::PostPublished, - PostStatus::PartiallyPublished => self::PostPartiallyPublished, PostStatus::Failed => self::PostFailed, PostStatus::Draft => $previous === PostStatus::Scheduled ? self::PostUnscheduled : null, PostStatus::PendingApproval => $previous === PostStatus::Scheduled ? self::PostUnscheduled : null, diff --git a/app/Events/PostPlatformStatusUpdated.php b/app/Events/PostStatusUpdated.php similarity index 67% rename from app/Events/PostPlatformStatusUpdated.php rename to app/Events/PostStatusUpdated.php index b4f540c336..51b4f4dc38 100644 --- a/app/Events/PostPlatformStatusUpdated.php +++ b/app/Events/PostStatusUpdated.php @@ -4,18 +4,18 @@ namespace App\Events; -use App\Models\PostPlatform; +use App\Models\Post; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; -class PostPlatformStatusUpdated implements ShouldBroadcast +class PostStatusUpdated implements ShouldBroadcast { use Dispatchable, InteractsWithSockets, SerializesModels; - public function __construct(public PostPlatform $postPlatform) {} + public function __construct(public Post $post) {} public function broadcastAs(): string { @@ -25,8 +25,7 @@ public function broadcastAs(): string public function broadcastOn(): array { return [ - new PrivateChannel("post.{$this->postPlatform->post_id}"), - new PrivateChannel("workspace.{$this->postPlatform->post->workspace_id}"), + new PrivateChannel("workspace.{$this->post->workspace_id}"), ]; } @@ -36,7 +35,7 @@ public function broadcastOn(): array public function broadcastWith(): array { return [ - 'post_id' => $this->postPlatform->post_id, + 'post_id' => $this->post->id, ]; } diff --git a/app/Http/Controllers/Api/ChannelQueueController.php b/app/Http/Controllers/Api/ChannelQueueController.php index ca0378e362..a10a43258d 100644 --- a/app/Http/Controllers/Api/ChannelQueueController.php +++ b/app/Http/Controllers/Api/ChannelQueueController.php @@ -32,7 +32,7 @@ public function moveToSlot(MoveChannelPostToQueueSlotRequest $request, SocialAcc $request->user(), ); - $post = Post::query()->with(['postPlatforms.socialAccount', 'labels'])->findOrFail($request->validated('post_id')); + $post = Post::query()->with(['socialAccount', 'labels'])->findOrFail($request->validated('post_id')); return new PostResource($post); } diff --git a/app/Http/Controllers/Api/PostApprovalController.php b/app/Http/Controllers/Api/PostApprovalController.php index a355d7f293..7fff48fa85 100644 --- a/app/Http/Controllers/Api/PostApprovalController.php +++ b/app/Http/Controllers/Api/PostApprovalController.php @@ -17,7 +17,7 @@ public function approve(ApprovePostRequest $request, Post $post): PostResource { /** @var Post $approved */ $approved = data_get(ApprovePost::execute($post, $request->user(), $request->validated()), 'post'); - $approved->load(['postPlatforms.socialAccount', 'labels']); + $approved->load(['socialAccount', 'labels']); return new PostResource($approved); } @@ -27,7 +27,7 @@ public function reject(Request $request, Post $post): PostResource $this->authorize('approve', $post); $rejected = RejectPost::execute($post, $request->user()); - $rejected->load(['postPlatforms.socialAccount', 'labels']); + $rejected->load(['socialAccount', 'labels']); return new PostResource($rejected); } diff --git a/app/Http/Controllers/Api/PostController.php b/app/Http/Controllers/Api/PostController.php index 99fa0892f2..1a70304faf 100644 --- a/app/Http/Controllers/Api/PostController.php +++ b/app/Http/Controllers/Api/PostController.php @@ -33,6 +33,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; +use Illuminate\Support\Arr; use Illuminate\Validation\ValidationException; use Symfony\Component\HttpFoundation\Response; @@ -48,7 +49,7 @@ public function index(ListPostsRequest $request): AnonymousResourceCollection ->visiblePendingApprovalsFor(BuildPublishPageProps::pendingApprovalsRequester($user, $workspace)) ->onChannels(data_get($filters, 'channels') ?: null) ->matchingLabelFilter(data_get($filters, 'labels'), data_get($filters, 'untagged')) - ->with(['postPlatforms.socialAccount', 'user', 'approvalRequestedBy', 'approver', 'labels']) + ->with(['socialAccount', 'user', 'approvalRequestedBy', 'approver', 'labels']) ->latestScheduledFirst() ->paginate((int) config('app.pagination.default')); @@ -66,7 +67,7 @@ public function show(Request $request, Post $post): PostResource ->exists(); abort_unless($visible, Response::HTTP_NOT_FOUND); - $post->load(['postPlatforms.socialAccount', 'user', 'labels']); + $post->load(['socialAccount', 'user', 'labels']); return new PostResource($post); } @@ -87,10 +88,10 @@ public function store(StorePostRequest $request): JsonResponse 'queue_slot' => $data['queue_slot'] ?? null, 'label_ids' => $data['label_ids'] ?? [], 'created_via' => CreatedVia::Api, - 'destinations' => [$data['platforms'][0]], + 'destinations' => [Arr::only($data, ['social_account_id', 'content_type', 'meta'])], ])->sole(); - $post->load(['postPlatforms.socialAccount', 'labels']); + $post->load(['socialAccount', 'labels']); return (new PostResource($post)) ->response() @@ -107,7 +108,7 @@ public function storeBatch(StorePostsRequest $request): JsonResponse return response()->json([ 'posts' => $posts->map(function (Post $post): array { - $post->load(['postPlatforms.socialAccount', 'labels']); + $post->load(['socialAccount', 'labels']); return (new PostResource($post))->resolve(); })->all(), @@ -132,7 +133,7 @@ public function update(UpdatePostRequest $request, Post $post): PostResource|Jso } $updated = data_get($result, 'post'); - $updated->load(['postPlatforms.socialAccount', 'labels']); + $updated->load(['socialAccount', 'labels']); return new PostResource($updated); } @@ -158,7 +159,7 @@ public function storeMedia(StoreMediaRequest $request, Post $post): PostResource AppendPostMedia::execute($post, [MediaItem::fromMedia($media)->toArray()], $request->user()); - $post->refresh()->load(['postPlatforms.socialAccount', 'labels']); + $post->refresh()->load(['socialAccount', 'labels']); return new PostResource($post); } @@ -173,7 +174,7 @@ public function attachMediaFromUpload(AttachMediaFromUploadRequest $request, Pos AppendPostMedia::execute($post, [MediaItem::fromMedia($media, $request->validated('alt'))->toArray()], $request->user()); - $post->refresh()->load(['postPlatforms.socialAccount', 'labels']); + $post->refresh()->load(['socialAccount', 'labels']); return new PostResource($post); } @@ -182,7 +183,7 @@ public function attachMediaFromUrl(AttachMediaFromUrlRequest $request, Post $pos { $result = app(MediaAttacher::class)->attachFromUrls($post, $request->validated('urls'), $request->user()); - $post->refresh()->load(['postPlatforms.socialAccount', 'labels']); + $post->refresh()->load(['socialAccount', 'labels']); return new PostMediaAttachResource($post, $result); } @@ -191,7 +192,7 @@ public function metrics(Request $request, Post $post): PostMetricsResource { $this->authorize('view', $post); - $post->load(['postPlatforms.socialAccount']); + $post->load(['socialAccount']); return new PostMetricsResource($post); } @@ -200,7 +201,7 @@ public function preview(Request $request, Post $post): PostPreviewResource { $this->authorize('view', $post); - $post->load(['postPlatforms.socialAccount']); + $post->load(['socialAccount']); return new PostPreviewResource($post); } diff --git a/app/Http/Controllers/Api/PostRecurrenceController.php b/app/Http/Controllers/Api/PostRecurrenceController.php index 10a2e3b029..08661e24d8 100644 --- a/app/Http/Controllers/Api/PostRecurrenceController.php +++ b/app/Http/Controllers/Api/PostRecurrenceController.php @@ -29,6 +29,6 @@ public function destroy(Post $post): PostResource private function resource(Post $post): PostResource { - return new PostResource($post->fresh(['postPlatforms.socialAccount', 'labels'])); + return new PostResource($post->fresh(['socialAccount', 'labels'])); } } diff --git a/app/Http/Controllers/App/Concerns/RendersPublishPage.php b/app/Http/Controllers/App/Concerns/RendersPublishPage.php index 485d08d4f3..f00384157b 100644 --- a/app/Http/Controllers/App/Concerns/RendersPublishPage.php +++ b/app/Http/Controllers/App/Concerns/RendersPublishPage.php @@ -29,7 +29,7 @@ private function renderPublishPage(Request $request, Workspace $workspace, ?Soci if (is_string($editPostId) && Str::isUuid($editPostId)) { $composerPost = $workspace->posts() - ->with(['postPlatforms' => fn ($query) => $query->enabled()->with('socialAccount'), 'labels']) + ->with(['socialAccount', 'labels']) ->findOrFail($editPostId); $this->authorize('update', $composerPost); $composerPost->unsetRelation('user')->unsetRelation('approvalRequestedBy'); @@ -111,12 +111,10 @@ private function redirectToComposer(Request $request, string $route, array $quer private function canOpenComposer(Post $post): bool { - $targets = $post->postPlatforms()->enabled()->get(); - - if ($targets->isEmpty()) { + if (! $post->hasDestination()) { return $post->status === PostStatus::Draft; } - return $targets->count() === 1 && $targets->first()->social_account_id !== null; + return $post->hasChannel(); } } diff --git a/app/Http/Controllers/App/PostController.php b/app/Http/Controllers/App/PostController.php index af183649c8..f63035351a 100644 --- a/app/Http/Controllers/App/PostController.php +++ b/app/Http/Controllers/App/PostController.php @@ -18,7 +18,6 @@ use App\Http\Requests\App\Post\StorePostRequest; use App\Http\Requests\App\Post\UpdatePostRequest; use App\Models\Post; -use App\Models\PostPlatform; use App\Support\PostStatusRules; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; @@ -137,15 +136,11 @@ public function store(StorePostRequest $request): RedirectResponse|HttpResponse ->with('created_post_ids', $posts->pluck('id')->all()); } - public function platformMetrics(Request $request, Post $post, PostPlatform $postPlatform): JsonResponse + public function metrics(Request $request, Post $post): JsonResponse { $this->authorize('view', $post); - if ($postPlatform->post_id !== $post->id) { - abort(404); - } - - return response()->json(app(ReadPublicationAnalytics::class)->forPlatform($postPlatform)); + return response()->json(app(ReadPublicationAnalytics::class)->metricsFor($post)); } public function edit(Request $request, Post $post): Response|RedirectResponse @@ -245,9 +240,9 @@ public function duplicate(Request $request, Post $post): RedirectResponse { $this->authorize('duplicate', $post); - $post->load(['postPlatforms', 'labels']); + $post->load(['socialAccount', 'labels']); - $copy = DuplicatePost::execute($post, $request->user(), $request->input('post_platform_id')); + $copy = DuplicatePost::execute($post, $request->user()); return redirect($this->publishPageReturnUrl(['edit' => $copy->id]) ?? route('app.posts.edit', $copy)); } diff --git a/app/Http/Controllers/App/PostGroupController.php b/app/Http/Controllers/App/PostGroupController.php index 8a60e92b6f..22b2e16268 100644 --- a/app/Http/Controllers/App/PostGroupController.php +++ b/app/Http/Controllers/App/PostGroupController.php @@ -25,7 +25,7 @@ public function show(Request $request, Post $post): AnonymousResourceCollection fn ($query) => $query->whereKey($post->id), ) ->with([ - 'postPlatforms' => fn ($platforms) => $platforms->enabled()->with('socialAccount'), + 'socialAccount', 'user.avatarMedia', 'labels', ]) @@ -34,7 +34,7 @@ public function show(Request $request, Post $post): AnonymousResourceCollection ->sortBy(fn (Post $sibling): array => [ $sibling->scheduled_at === null ? 1 : 0, $sibling->scheduled_at?->getTimestamp() ?? 0, - $sibling->postPlatforms->first()?->socialAccount?->position ?? PHP_INT_MAX, + $sibling->socialAccount?->position ?? PHP_INT_MAX, ]) ->values(); diff --git a/app/Http/Requests/Api/Post/SetPostRecurrenceRequest.php b/app/Http/Requests/Api/Post/SetPostRecurrenceRequest.php index 9371e7089b..d8289a4620 100644 --- a/app/Http/Requests/Api/Post/SetPostRecurrenceRequest.php +++ b/app/Http/Requests/Api/Post/SetPostRecurrenceRequest.php @@ -7,7 +7,6 @@ use App\Enums\Post\RecurrenceFrequency; use App\Enums\Post\Status as PostStatus; use App\Models\Post; -use App\Models\PostPlatform; use App\Support\PostingSchedule; use App\Support\Timezone; use Carbon\CarbonImmutable; @@ -68,11 +67,10 @@ function (Validator $validator): void { $frequency = RecurrenceFrequency::from($this->string('frequency')->toString()); $steps = $this->integer('interval') * $this->integer('times'); $ceiling = CarbonImmutable::parse(PostingSchedule::MAX_INSTANT, 'UTC'); - $tooFar = $post->postPlatforms()->enabled()->with('socialAccount')->get() - ->contains(fn (PostPlatform $target): bool => $frequency->advance( - $post->scheduled_at->toImmutable()->setTimezone(Timezone::normalize($target->socialAccount?->timezone)), - $steps, - )->greaterThan($ceiling)); + $tooFar = $post->hasDestination() && $frequency->advance( + $post->scheduled_at->toImmutable()->setTimezone(Timezone::normalize($post->socialAccount?->timezone)), + $steps, + )->greaterThan($ceiling); if ($tooFar) { $validator->errors()->add('times', __('posts.recurrence.errors.too_far')); diff --git a/app/Http/Requests/Api/Post/UpdatePostRequest.php b/app/Http/Requests/Api/Post/UpdatePostRequest.php index 337995bbce..21d00894c8 100644 --- a/app/Http/Requests/Api/Post/UpdatePostRequest.php +++ b/app/Http/Requests/Api/Post/UpdatePostRequest.php @@ -15,11 +15,6 @@ public function authorize(): bool return true; } - protected function prepareForValidation(): void - { - $this->replace(PostRequestRules::updateInput($this->route('post'), $this->all())); - } - public function rules(): array { return PostRequestRules::update($this->user()->currentWorkspace, $this->route('post'), $this->all()); diff --git a/app/Http/Requests/App/Post/UpdatePostRecurrenceRequest.php b/app/Http/Requests/App/Post/UpdatePostRecurrenceRequest.php index c09862c69a..57fb7e7fa3 100644 --- a/app/Http/Requests/App/Post/UpdatePostRecurrenceRequest.php +++ b/app/Http/Requests/App/Post/UpdatePostRecurrenceRequest.php @@ -7,7 +7,6 @@ use App\Enums\Post\RecurrenceFrequency; use App\Enums\Post\Status as PostStatus; use App\Models\Post; -use App\Models\PostPlatform; use App\Support\PostingSchedule; use App\Support\Timezone; use Carbon\CarbonImmutable; @@ -69,11 +68,10 @@ function (Validator $validator): void { $frequency = RecurrenceFrequency::from($this->string('frequency')->toString()); $steps = $this->integer('interval') * $this->integer('times'); $ceiling = CarbonImmutable::parse(PostingSchedule::MAX_INSTANT, 'UTC'); - $tooFar = $post->postPlatforms()->enabled()->with('socialAccount')->get() - ->contains(fn (PostPlatform $target): bool => $frequency->advance( - $post->scheduled_at->toImmutable()->setTimezone(Timezone::normalize($target->socialAccount?->timezone)), - $steps, - )->greaterThan($ceiling)); + $tooFar = $post->hasDestination() && $frequency->advance( + $post->scheduled_at->toImmutable()->setTimezone(Timezone::normalize($post->socialAccount?->timezone)), + $steps, + )->greaterThan($ceiling); if ($tooFar) { $validator->errors()->add('times', __('posts.recurrence.errors.too_far')); diff --git a/app/Http/Requests/App/Post/UpdatePostRequest.php b/app/Http/Requests/App/Post/UpdatePostRequest.php index b0c2ebb26d..fda8c1b4f3 100644 --- a/app/Http/Requests/App/Post/UpdatePostRequest.php +++ b/app/Http/Requests/App/Post/UpdatePostRequest.php @@ -6,19 +6,18 @@ use App\Enums\Post\Status; use App\Enums\PostPlatform\ContentType; -use App\Enums\SocialAccount\Platform; -use App\Models\PostPlatform; -use App\Models\SocialAccount; use App\Rules\ContentFitsPlatformLimits; use App\Rules\ContentTypeCompatibleWithMedia; +use App\Rules\ContentTypeMatchesPostChannel; use App\Rules\PostContentFitsMaxLength; use App\Support\PostMediaRules; use App\Support\PostPlatformMetaRules; use App\Support\PostStatusRules; use Illuminate\Foundation\Http\FormRequest; -use Illuminate\Support\Collection; +use Illuminate\Http\Exceptions\HttpResponseException; use Illuminate\Validation\Rule; use Illuminate\Validation\Validator; +use Inertia\Inertia; class UpdatePostRequest extends FormRequest { @@ -27,8 +26,20 @@ public function authorize(): bool return true; } + /** + * A tab opened before posts and their destinations were merged still + * sends `platforms[]`; reload it instead of dropping the edit. + */ + protected function prepareForValidation(): void + { + if ($this->has('platforms')) { + throw new HttpResponseException(Inertia::location(url()->previous())); + } + } + public function rules(): array { + $post = $this->route('post'); $status = $this->input('status'); $enforcesMediaCompatibility = in_array( @@ -36,6 +47,7 @@ public function rules(): array [Status::Scheduled->value, Status::Publishing->value], true, ); + $target = $post->socialAccount ?? $post->platform; return [ 'status' => ['required', 'string', Rule::in([Status::Draft->value, Status::Scheduled->value, Status::Publishing->value])], @@ -45,22 +57,23 @@ public function rules(): array new PostContentFitsMaxLength, Rule::when( $enforcesMediaCompatibility, - [new ContentFitsPlatformLimits($this->resolveSelectedPlatforms(), PostPlatformMetaRules::metaByKey($this->input('platforms', []), 'id'), PostPlatformMetaRules::contentTypesByKey($this->input('platforms', []), 'id', $this->storedContentTypes()))] + [new ContentFitsPlatformLimits( + collect($target === null ? [] : ['post' => $target]), + ['post' => $this->effectiveMeta()], + ['post' => $this->input('content_type') ?? $post->content_type?->value], + )] ), ], ...PostMediaRules::hostedRules(), - 'scheduled_at' => PostStatusRules::scheduledAtRules($this->route('post'), $status, $this->filled('queue')), + 'scheduled_at' => PostStatusRules::scheduledAtRules($post, $status, $this->filled('queue')), 'queue' => PostStatusRules::queueRules(), 'social_account_id' => ['prohibited'], - 'content_type' => ['sometimes', 'string', Rule::in(array_column(ContentType::cases(), 'value'))], - 'meta' => ['sometimes', 'array'], - 'platforms' => ['sometimes', 'array'], - 'platforms.*.id' => ['required', 'uuid', Rule::exists('post_platforms', 'id')->where('post_id', $this->route('post')->id)], - 'platforms.*.content_type' => [ - $enforcesMediaCompatibility ? 'required' : 'sometimes', + 'content_type' => [ + $enforcesMediaCompatibility && $post->hasChannel() ? 'required' : 'sometimes', 'string', Rule::in(array_column(ContentType::cases(), 'value')), - Rule::when($enforcesMediaCompatibility, [new ContentTypeCompatibleWithMedia(workspace: $this->route('post')->workspace)]), + new ContentTypeMatchesPostChannel($post), + Rule::when($enforcesMediaCompatibility, [new ContentTypeCompatibleWithMedia(workspace: $post->workspace)]), ], ...PostPlatformMetaRules::rules(), 'label_ids' => ['sometimes', 'array'], @@ -87,25 +100,13 @@ public function attributes(): array public function withValidator(Validator $validator): void { $validator->after(function (Validator $validator): void { - if (! $this->isPublishingOrScheduling()) { + $post = $this->route('post'); + + if (! $this->isPublishingOrScheduling() || ! $post->hasDestination()) { return; } - $platforms = $this->input('platforms', []); - $ids = collect($platforms)->pluck('id')->filter()->all(); - - $platformsById = $this->route('post') - ->postPlatforms() - ->with('socialAccount') - ->whereIn('id', $ids) - ->get() - ->mapWithKeys(fn (PostPlatform $postPlatform): array => [$postPlatform->id => $postPlatform->socialAccount ?? $postPlatform->platform]); - - PostPlatformMetaRules::addRequiredOnPublishErrors( - $validator, - $platforms, - fn ($platform) => $platformsById[data_get($platform, 'id')] ?? null, - ); + PostPlatformMetaRules::addRequiredOnPublishErrors($validator, $post->socialAccount ?? $post->platform, $this->effectiveMeta()); }); } @@ -119,33 +120,12 @@ private function isPublishingOrScheduling(): bool } /** - * @return Collection + * The meta the save would store: the submitted keys over the stored ones. + * + * @return array */ - /** - * @return array - */ - private function storedContentTypes(): array - { - return $this->route('post')->postPlatforms()->pluck('content_type', 'id') - ->map(fn (?ContentType $contentType): ?string => $contentType?->value) - ->all(); - } - - private function resolveSelectedPlatforms(): Collection + private function effectiveMeta(): array { - $ids = collect($this->input('platforms', []))->pluck('id')->filter()->all(); - - if (empty($ids)) { - return collect(); - } - - return $this->route('post') - ->postPlatforms() - ->whereIn('id', $ids) - ->with('socialAccount') - ->get() - ->mapWithKeys(fn (PostPlatform $postPlatform): array => [ - $postPlatform->id => $postPlatform->socialAccount ?? $postPlatform->platform, - ]); + return [...($this->route('post')->meta ?? []), ...(array) $this->input('meta', [])]; } } diff --git a/app/Http/Resources/Api/PostMetricsResource.php b/app/Http/Resources/Api/PostMetricsResource.php index e2fa6abd27..d726646102 100644 --- a/app/Http/Resources/Api/PostMetricsResource.php +++ b/app/Http/Resources/Api/PostMetricsResource.php @@ -18,9 +18,6 @@ class PostMetricsResource extends JsonResource */ public function toArray(Request $request): array { - return [ - 'post_id' => $this->id, - 'platforms' => app(ReadPublicationAnalytics::class)->forPost($this->resource)->all(), - ]; + return app(ReadPublicationAnalytics::class)->forPost($this->resource); } } diff --git a/app/Http/Resources/Api/PostPlatformResource.php b/app/Http/Resources/Api/PostPlatformResource.php deleted file mode 100644 index 3995c87c6d..0000000000 --- a/app/Http/Resources/Api/PostPlatformResource.php +++ /dev/null @@ -1,35 +0,0 @@ - - */ - public function toArray(Request $request): array - { - return [ - 'id' => $this->id, - 'platform' => $this->platform?->value, - 'content_type' => $this->content_type?->value, - 'meta' => $this->meta, - 'status' => $this->status?->value, - 'enabled' => $this->enabled, - 'platform_url' => $this->platform_url, - 'published_at' => $this->published_at?->format('Y-m-d H:i:s'), - 'error_message' => $this->error_message, - // Display fields fall back to snapshots (platform_name/username/avatar) - // so deleted accounts still render correctly in the post history. - 'display_name' => $this->display_name, - 'display_username' => $this->display_username, - 'display_avatar' => $this->display_avatar, - 'social_account' => new SocialAccountResource($this->whenLoaded('socialAccount')), - ]; - } -} diff --git a/app/Http/Resources/Api/PostResource.php b/app/Http/Resources/Api/PostResource.php index 44825305af..9bb12ca671 100644 --- a/app/Http/Resources/Api/PostResource.php +++ b/app/Http/Resources/Api/PostResource.php @@ -42,7 +42,16 @@ public function toArray(Request $request): array 'remaining' => $this->recurrence_remaining, ] : null, 'origin' => $this->origin->value, - 'platforms' => PostPlatformResource::collection($this->whenLoaded('postPlatforms')), + 'publish_status' => $this->publish_status->value, + 'platform' => $this->platform?->value, + 'content_type' => $this->content_type?->value, + 'meta' => $this->meta, + 'platform_url' => $this->platform_url, + 'error_message' => $this->error_message, + 'display_name' => $this->display_name, + 'display_username' => $this->display_username, + 'display_avatar' => $this->display_avatar, + 'social_account' => new SocialAccountResource($this->whenLoaded('socialAccount')), 'labels' => LabelResource::collection($this->whenLoaded('labels')), 'created_at' => $this->created_at->format('Y-m-d H:i:s'), 'updated_at' => $this->updated_at->format('Y-m-d H:i:s'), diff --git a/app/Http/Resources/Api/RepurposeItemResource.php b/app/Http/Resources/Api/RepurposeItemResource.php index ec4bd39381..78929d7d83 100644 --- a/app/Http/Resources/Api/RepurposeItemResource.php +++ b/app/Http/Resources/Api/RepurposeItemResource.php @@ -25,14 +25,8 @@ public function toArray(Request $request): array 'error' => $this->error, 'posts' => $this->whenLoaded('posts', fn () => $this->posts->map(fn ($post) => [ 'id' => $post->id, - 'platforms' => $post->postPlatforms - ->where('enabled', true) - ->map(fn ($postPlatform) => [ - 'platform' => $postPlatform->platform?->value, - 'status' => $postPlatform->status?->value, - ]) - ->filter(fn (array $entry): bool => $entry['platform'] !== null) - ->values(), + 'platform' => $post->platform?->value, + 'publish_status' => $post->publish_status->value, ])->values()), 'created_at' => $this->created_at->toIso8601String(), 'updated_at' => $this->updated_at->toIso8601String(), diff --git a/app/Http/Resources/App/HandleInertiaRequests/SidebarChannelResource.php b/app/Http/Resources/App/HandleInertiaRequests/SidebarChannelResource.php index 1ccb4b05b4..1b59e4b262 100644 --- a/app/Http/Resources/App/HandleInertiaRequests/SidebarChannelResource.php +++ b/app/Http/Resources/App/HandleInertiaRequests/SidebarChannelResource.php @@ -18,11 +18,9 @@ class SidebarChannelResource public static function collection(Workspace $workspace): array { return $workspace->socialAccounts() - ->withCount(['postPlatforms as scheduled_posts_count' => fn (Builder $query) => $query - ->where('enabled', true) - ->whereHas('post', fn (Builder $post) => $post - ->where('workspace_id', $workspace->id) - ->where('status', PostStatus::Scheduled))]) + ->withCount(['posts as scheduled_posts_count' => fn (Builder $query) => $query + ->where('workspace_id', $workspace->id) + ->where('status', PostStatus::Scheduled)]) ->get() ->map(fn (SocialAccount $account): array => [ 'id' => $account->id, diff --git a/app/Http/Resources/App/InstagramGridTileResource.php b/app/Http/Resources/App/InstagramGridTileResource.php index 8dac2b01c5..f653f4b50a 100644 --- a/app/Http/Resources/App/InstagramGridTileResource.php +++ b/app/Http/Resources/App/InstagramGridTileResource.php @@ -7,13 +7,13 @@ use App\Dto\MediaItem; use App\Enums\Media\Type as MediaType; use App\Enums\PostPlatform\ContentType; -use App\Models\PostPlatform; +use App\Models\Post; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Support\Collection; /** - * @mixin PostPlatform + * @mixin Post */ class InstagramGridTileResource extends JsonResource { @@ -24,12 +24,12 @@ class InstagramGridTileResource extends JsonResource */ public function toArray(Request $request): array { - $visuals = $this->post->mediaItems + $visuals = $this->mediaItems ->filter(fn (MediaItem $item): bool => $item->isImage() || $item->isVideo()) ->values(); return [ - 'id' => $this->post_id, + 'id' => $this->id, 'published_at' => $this->published_at, 'kind' => $this->kind($visuals), 'items' => $visuals->map(fn (MediaItem $item): array => [ diff --git a/app/Http/Resources/App/PostCardResource.php b/app/Http/Resources/App/PostCardResource.php index aa61c9e6a7..f13efe7790 100644 --- a/app/Http/Resources/App/PostCardResource.php +++ b/app/Http/Resources/App/PostCardResource.php @@ -21,7 +21,13 @@ class PostCardResource extends JsonResource */ public function toArray(Request $request): array { - $card = $this->resource->toArray(); + $card = [ + ...$this->resource->toArray(), + 'failure' => $this->resource->error_context === null ? null : [ + 'category' => data_get($this->resource->error_context, 'category'), + 'failed_at' => data_get($this->resource->error_context, 'failed_at'), + ], + ]; if ($this->resource->relationLoaded('user')) { $card['user'] = $this->user === null ? null : [ diff --git a/app/Jobs/Analytics/BackfillTryPostPublications.php b/app/Jobs/Analytics/BackfillTryPostPublications.php deleted file mode 100644 index f69fa2ad62..0000000000 --- a/app/Jobs/Analytics/BackfillTryPostPublications.php +++ /dev/null @@ -1,69 +0,0 @@ - */ - public function backoff(): array - { - return [60, 300]; - } - - /** @param list $postPlatformIds */ - public function __construct(public array $postPlatformIds) - { - $this->onQueue('analytics'); - } - - public function handle(SyncTryPostPublication $sync, ResolveAnalyticsAccountKey $accountKeys): void - { - $identities = []; - - PostPlatform::query() - ->published() - ->includedInAnalytics() - ->whereIn('id', $this->postPlatformIds) - ->whereNotNull('social_account_id') - ->with(['post', 'socialAccount']) - ->get() - ->each(function (PostPlatform $postPlatform) use ($sync, $accountKeys, &$identities): void { - $account = $postPlatform->socialAccount; - - if (! $account) { - return; - } - - $identities[$account->id] ??= TryPostPublicationIdentity::fromAccount( - $account, - $accountKeys->for($account), - ); - - $sync->fromIdentity($identities[$account->id], $postPlatform, $account); - }); - } - - public function failed(?Throwable $exception): void - { - Log::error('Analytics TryPost publication backfill failed', [ - 'first_post_platform_id' => $this->postPlatformIds[0] ?? null, - 'post_platform_count' => count($this->postPlatformIds), - 'exception' => $exception, - ]); - } -} diff --git a/app/Jobs/Analytics/CollectPublicationMetrics.php b/app/Jobs/Analytics/CollectPublicationMetrics.php index f2bae2e66d..981415bfec 100644 --- a/app/Jobs/Analytics/CollectPublicationMetrics.php +++ b/app/Jobs/Analytics/CollectPublicationMetrics.php @@ -111,7 +111,7 @@ public function handle( $collector = $collectors->for($publication->platform); if ($collector instanceof TikTokPublicationMetricsCollector - && $publication->post_platform_id + && $publication->post_id && ! ctype_digit($publication->remote_id)) { $publications->reconcileRemoteId($publication, $collector->publicVideoId($publication)); } diff --git a/app/Jobs/Analytics/SyncTryPostPublication.php b/app/Jobs/Analytics/SyncTryPostPublication.php index 8f619188d0..0578ebbb6e 100644 --- a/app/Jobs/Analytics/SyncTryPostPublication.php +++ b/app/Jobs/Analytics/SyncTryPostPublication.php @@ -7,7 +7,7 @@ use App\Actions\Analytics\QueuePublicationMetricsForPage; use App\Actions\Analytics\SyncTryPostPublication as SyncTryPostPublicationAction; use App\Dto\Analytics\TryPostPublicationIdentity; -use App\Models\PostPlatform; +use App\Models\Post; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; @@ -17,23 +17,22 @@ class SyncTryPostPublication implements ShouldQueue public function __construct( public TryPostPublicationIdentity $identity, - public string $postPlatformId, + public string $postId, ) { $this->onQueue('analytics'); } public function handle(SyncTryPostPublicationAction $sync, QueuePublicationMetricsForPage $metrics): void { - $postPlatform = PostPlatform::query() - ->published() - ->with('post') - ->find($this->postPlatformId); + $post = Post::query() + ->publicationPublished() + ->find($this->postId); - if (! $postPlatform || ! filled($postPlatform->platform_post_id)) { + if (! $post || ! filled($post->platform_post_id)) { return; } - $publication = $sync->fromIdentity($this->identity, $postPlatform); + $publication = $sync->fromIdentity($this->identity, $post); $metrics->queue($publication); } } diff --git a/app/Jobs/Post/ImportExternalPostMedia.php b/app/Jobs/Post/ImportExternalPostMedia.php index 9ec65f1927..30b567a250 100644 --- a/app/Jobs/Post/ImportExternalPostMedia.php +++ b/app/Jobs/Post/ImportExternalPostMedia.php @@ -15,7 +15,6 @@ use App\Models\AnalyticsPublication; use App\Models\Media; use App\Models\Post; -use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Models\Workspace; use App\Services\Media\RemoteMediaImporter; @@ -117,7 +116,7 @@ public function failed(?Throwable $exception): void private function platform(): ?Platform { - return once(fn (): ?Platform => PostPlatform::query()->where('post_id', $this->postId)->value('platform')); + return once(fn (): ?Platform => Post::query()->find($this->postId, ['id', 'platform'])?->platform); } /** @@ -127,10 +126,9 @@ private function target(): array { $post = Post::query() ->imported() - ->with(['postPlatforms.socialAccount', 'postPlatforms.analyticsPublication']) + ->with(['socialAccount', 'analyticsPublication']) ->find($this->postId); - $target = $post?->postPlatforms->first(); - $publication = $target?->analyticsPublication; + $publication = $post?->analyticsPublication; if ($post === null || $publication === null diff --git a/app/Jobs/PostHog/SyncAccountPublishingActivity.php b/app/Jobs/PostHog/SyncAccountPublishingActivity.php index 749a79213f..ad0369a574 100644 --- a/app/Jobs/PostHog/SyncAccountPublishingActivity.php +++ b/app/Jobs/PostHog/SyncAccountPublishingActivity.php @@ -6,7 +6,7 @@ use App\Models\Account; use App\Models\Post; -use App\Models\PostPlatform; +use App\Models\Workspace; use App\Services\PostHogService; use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing; use Illuminate\Contracts\Queue\ShouldQueue; @@ -67,23 +67,19 @@ public function handle(PostHogService $postHog): void return; } - $postIds = Post::query() - ->select('posts.id') - ->whereRelation('workspace', 'account_id', $account->id); - - $latestPublication = PostPlatform::query() - ->published() + $latestPublication = Post::query() + ->whereIn('workspace_id', Workspace::query()->select('id')->where('account_id', $account->id)) + ->publicationPublished() ->whereNotNull('published_at') - ->whereIn('post_id', $postIds) ->latest('published_at') - ->latest('updated_at') + ->latest('publication_updated_at') ->latest('id') ->first(); $postHog->groupIdentifyNow('account', (string) $account->id, [ 'last_post_published_at' => $latestPublication?->published_at?->toIso8601String(), - 'last_post_published_network' => $latestPublication?->platform->network(), - 'last_published_post_id' => $latestPublication?->post_id, + 'last_post_published_network' => $latestPublication?->platform?->network(), + 'last_published_post_id' => $latestPublication?->id, ]); } } diff --git a/app/Jobs/PublishPost.php b/app/Jobs/PublishPost.php index 0ac71b3202..ea788f2a99 100644 --- a/app/Jobs/PublishPost.php +++ b/app/Jobs/PublishPost.php @@ -23,9 +23,13 @@ public function handle(): void { $this->post->markAsPublishing(); - foreach ($this->post->postPlatforms()->enabled()->get() as $postPlatform) { - PublishToSocialPlatform::dispatch($postPlatform); + if (! $this->post->hasChannel()) { + app(FinalizePostPublication::class)->handle($this->post); + + return; } + + PublishToSocialPlatform::dispatch($this->post); } public function failed(?Throwable $exception): void diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index cdeef18677..7692c9f556 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -7,15 +7,15 @@ use App\Actions\Post\FinalizePostPublication; use App\Enums\GoogleBusiness\LocalPostState; use App\Enums\Media\Type as MediaType; -use App\Enums\PostPlatform\Status as PostPlatformStatus; +use App\Enums\Post\PublishStatus; use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\SocialAccount\Status; -use App\Events\PostPlatformStatusUpdated; +use App\Events\PostStatusUpdated; use App\Exceptions\PlatformUnavailableException; use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\SocialPublishException; use App\Exceptions\TokenExpiredException; -use App\Models\PostPlatform; +use App\Models\Post; use App\Rules\ContentTypeCompatibleWithMedia; use App\Services\Social\BlueskyPublisher; use App\Services\Social\ConnectionVerifier; @@ -65,15 +65,15 @@ class PublishToSocialPlatform implements ShouldBeUnique, ShouldQueue private const int DEFAULT_RETRY_DELAY_SECONDS = 600; public function __construct( - public PostPlatform $postPlatform, + public Post $post, public int $uniqueAttempt = 0, ) { - $this->onQueue($postPlatform->platform->queue()); + $this->onQueue($post->platform->queue()); } public function uniqueId(): string { - return "{$this->postPlatform->id}:{$this->uniqueAttempt}"; + return "{$this->post->id}:{$this->uniqueAttempt}"; } /** @@ -82,7 +82,7 @@ public function uniqueId(): string public function middleware(): array { return [ - (new WithoutOverlapping("social-publish:{$this->postPlatform->id}")) + (new WithoutOverlapping("social-publish:{$this->post->id}")) ->releaseAfter(60) ->expireAfter($this->timeout + 60), ]; @@ -90,19 +90,19 @@ public function middleware(): array public function handle(): void { - $this->postPlatform->refresh(); + $this->post->refresh(); - if ($this->postPlatform->status->isClosed() || $this->postPlatform->isWaitingForLimitRetry()) { + if ($this->post->publish_status->isClosed() || $this->post->isPublicationWaitingForLimitRetry()) { return; } - if ($this->postPlatform->socialAccount->status === Status::Disconnected) { + if ($this->post->socialAccount->status === Status::Disconnected) { $this->failAndFinalize(__('posts.errors.account_disconnected')); return; } - if ($this->postPlatform->socialAccount->status === Status::TokenExpired) { + if ($this->post->socialAccount->status === Status::TokenExpired) { $this->failAndFinalize(__('posts.errors.account_token_expired'), [ 'category' => ErrorCategory::TokenExpired->value, 'failed_at' => now()->toIso8601String(), @@ -119,7 +119,7 @@ public function handle(): void return; } - $this->postPlatform->markAsPublishing(); + $this->post->markPublicationPublishing(); $this->broadcastStatus(); $maxAttempts = 2; // Original attempt + 1 retry after token refresh @@ -127,7 +127,7 @@ public function handle(): void for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { try { $publisher = $this->getPublisher(); - $result = $publisher->publish($this->postPlatform); + $result = $publisher->publish($this->post); $this->recordPublishResult($result); break; @@ -198,11 +198,11 @@ private function recordPublishResult(array $result): void $threadReplyIds = data_get($result, 'thread_reply_ids'); if (is_array($threadReplyIds) && $threadReplyIds !== []) { - $this->postPlatform->thread_reply_ids = array_values($threadReplyIds); + $this->post->thread_reply_ids = array_values($threadReplyIds); } match ($state) { - LocalPostState::Rejected => $this->postPlatform->markAsRejected( + LocalPostState::Rejected => $this->post->markPublicationRejected( $platformPostId, $platformUrl, __('posts.errors.rejected_in_review'), @@ -210,23 +210,23 @@ private function recordPublishResult(array $result): void ), LocalPostState::Processing, LocalPostState::Scheduled, - LocalPostState::Unspecified => $this->postPlatform->markAsPendingReview($platformPostId, $platformUrl), + LocalPostState::Unspecified => $this->post->markPublicationPendingReview($platformPostId, $platformUrl), LocalPostState::Live, LocalPostState::Recurring, - null => $this->postPlatform->markAsPublished($platformPostId, $platformUrl), + null => $this->post->markPublicationPublished($platformPostId, $platformUrl), }; } private function refreshAccountToken(): void { - app(ConnectionVerifier::class)->verify($this->postPlatform->socialAccount); + app(ConnectionVerifier::class)->verify($this->post->socialAccount); } private function failForMissingScopes(): bool { $missingScopes = array_values(array_diff( - $this->postPlatform->platform->requiredPublishScopes(), - $this->postPlatform->socialAccount->scopes ?? [], + $this->post->platform->requiredPublishScopes(), + $this->post->socialAccount->scopes ?? [], )); if ($missingScopes === []) { @@ -256,9 +256,9 @@ private function failForMissingScopes(): bool */ private function failForInvalidMedia(): bool { - $context = $this->postPlatform->error_context; + $context = $this->post->error_context; - if ($this->postPlatform->scheduled_before_media_checks) { + if ($this->post->scheduled_before_media_checks) { return false; } @@ -266,15 +266,14 @@ private function failForInvalidMedia(): bool return false; } - $post = $this->postPlatform->post; $errors = ContentTypeCompatibleWithMedia::errorsFor( [[ 'key' => 'media', - 'content_type' => $this->postPlatform->content_type?->value, + 'content_type' => $this->post->content_type?->value, ]], - (array) ($post->media ?? []), - $post->workspace, - ) ?: ThreadReplies::mediaErrors($this->postPlatform->platform, $this->postPlatform->meta, $post->workspace); + (array) ($this->post->media ?? []), + $this->post->workspace, + ) ?: ThreadReplies::mediaErrors($this->post->platform, $this->post->meta, $this->post->workspace); if ($errors === []) { return false; @@ -290,7 +289,7 @@ private function failForInvalidMedia(): bool private function rescheduleForRetry(PlatformUnavailableException $e): void { - $previousContext = self::withSeparateRetryCounters($this->postPlatform->error_context ?? []); + $previousContext = self::withSeparateRetryCounters($this->post->error_context ?? []); $hasOwnPolicy = $e->maxRetries !== null || $e->retryDelaySeconds !== null; $counterKey = $hasOwnPolicy ? 'processing_retry_count' : 'retry_count'; $retryCount = (int) data_get($previousContext, $counterKey, 0) + 1; @@ -323,14 +322,14 @@ private function rescheduleForRetry(PlatformUnavailableException $e): void $nextAttemptAt = now()->addSeconds($retryDelaySeconds); Log::warning('Publish rescheduled: platform unavailable', [ - 'post_platform_id' => $this->postPlatform->id, - 'platform' => $this->postPlatform->platform->value, + 'post_id' => $this->post->id, + 'platform' => $this->post->platform->value, 'next_attempt_at' => $nextAttemptAt->toIso8601String(), ...$context, ]); - $this->postPlatform->update([ - 'status' => PostPlatformStatus::Retrying, + $this->post->writePublication([ + 'publish_status' => PublishStatus::Retrying, 'error_message' => __('posts.errors.platform_unavailable'), 'error_context' => [ ...$context, @@ -340,7 +339,7 @@ private function rescheduleForRetry(PlatformUnavailableException $e): void ]); self::dispatch( - $this->postPlatform, + $this->post, (int) data_get($context, 'retry_count', 0) + (int) data_get($context, 'processing_retry_count', 0), )->delay($nextAttemptAt); } @@ -376,7 +375,7 @@ private static function withSeparateRetryCounters(array $context): array */ private function waitForLimitRetry(SocialPublishException $e): bool { - $previousContext = $this->postPlatform->error_context ?? []; + $previousContext = $this->post->error_context ?? []; $retries = LimitRetryPolicy::retriesSoFar($previousContext); $retryAt = LimitRetryPolicy::nextAttemptAt($retries, $e->retryAt); @@ -384,9 +383,9 @@ private function waitForLimitRetry(SocialPublishException $e): bool return false; } - match ($this->postPlatform->platform) { - SocialPlatform::TikTok => app(TikTokPhotoDerivativeCleaner::class)->cleanup($previousContext, $this->postPlatform->id), - SocialPlatform::GoogleBusiness => app(GoogleBusinessDerivativeCleaner::class)->cleanup($this->postPlatform->id), + match ($this->post->platform) { + SocialPlatform::TikTok => app(TikTokPhotoDerivativeCleaner::class)->cleanup($previousContext, $this->post->id), + SocialPlatform::GoogleBusiness => app(GoogleBusinessDerivativeCleaner::class)->cleanup($this->post), default => null, }; @@ -405,14 +404,14 @@ private function waitForLimitRetry(SocialPublishException $e): bool ]; Log::warning('Publish waiting for a network limit', [ - 'post_platform_id' => $this->postPlatform->id, - 'platform' => $this->postPlatform->platform->value, + 'post_id' => $this->post->id, + 'platform' => $this->post->platform->value, 'platform_error_code' => $e->platformErrorCode, 'limit_retries' => $retries + 1, 'retry_at' => $retryAt->toIso8601String(), ]); - $this->postPlatform->markAsWaitingForLimitRetry($retryAt, $e->userMessage, $context); + $this->post->markPublicationWaitingForLimitRetry($retryAt, $e->userMessage, $context); return true; } @@ -429,13 +428,13 @@ private function reportCaughtPublishFailure(Throwable $e, array $context = []): { Log::error('Social publish failed', [ ...(method_exists($e, 'context') ? $e->context() : []), - 'post_platform_id' => $this->postPlatform->id, - 'platform' => $this->postPlatform->platform->value, - 'content_type' => $this->postPlatform->content_type?->value, + 'post_id' => $this->post->id, + 'platform' => $this->post->platform->value, + 'content_type' => $this->post->content_type?->value, 'exception' => $e::class, 'message' => $e->getMessage(), ...$context, - 'media' => $this->mediaSnapshot($this->postPlatform), + 'media' => $this->mediaSnapshot(), ]); if ($e instanceof TokenExpiredException || ($e instanceof SocialPublishException && $e->isNetworkRejection())) { @@ -452,9 +451,9 @@ private function reportCaughtPublishFailure(Throwable $e, array $context = []): * * @return list */ - private function mediaSnapshot(PostPlatform $postPlatform): array + private function mediaSnapshot(): array { - $media = $postPlatform->post?->media; + $media = $this->post->media; if (! is_array($media)) { return []; @@ -486,7 +485,7 @@ private function failWithExpiredToken(TokenExpiredException $e): void 'platform_error_code' => $e->platformErrorCode, 'failed_at' => now()->toIso8601String(), ]); - $this->postPlatform->socialAccount->markAsTokenExpired($e->getMessage()); + $this->post->socialAccount->markAsTokenExpired($e->getMessage()); } /** @@ -494,21 +493,21 @@ private function failWithExpiredToken(TokenExpiredException $e): void */ private function markPlatformAsFailed(string $message, ?array $context = null): void { - $previousContext = $this->postPlatform->error_context ?? []; + $previousContext = $this->post->error_context ?? []; - match ($this->postPlatform->platform) { + match ($this->post->platform) { SocialPlatform::TikTok => app(TikTokPhotoDerivativeCleaner::class)->cleanupUnlessPublishInFlight( $previousContext, - $this->postPlatform->id, + $this->post->id, ), - SocialPlatform::GoogleBusiness => app(GoogleBusinessDerivativeCleaner::class)->cleanup($this->postPlatform->id), + SocialPlatform::GoogleBusiness => app(GoogleBusinessDerivativeCleaner::class)->cleanup($this->post), default => null, }; $failureContext = [...$previousContext, ...($context ?? [])]; - $this->postPlatform->markAsFailed( - ThreadProgress::failureMessage($this->postPlatform, $message, $failureContext), + $this->post->markPublicationFailed( + ThreadProgress::failureMessage($this->post, $message, $failureContext), $failureContext === [] ? null : $failureContext, ); } @@ -522,8 +521,8 @@ private function failureContext(array $extra = []): array return [ ...$extra, 'failed_at' => now()->toIso8601String(), - 'content_length' => mb_strlen($this->postPlatform->post->content ?? ''), - 'media_count' => count($this->postPlatform->post->media ?? []), + 'content_length' => mb_strlen($this->post->content ?? ''), + 'media_count' => count($this->post->media ?? []), ]; } @@ -539,7 +538,7 @@ private function failAndFinalize(string $message, ?array $context = null): void private function broadcastStatus(): void { - PostPlatformStatusUpdated::dispatch($this->postPlatform->fresh()); + PostStatusUpdated::dispatch($this->post->fresh()); } /** @@ -555,7 +554,7 @@ private function safeFailureMessage(Throwable $e): string private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher|BlueskyPublisher|MastodonPublisher|TelegramPublisher|DiscordPublisher|GoogleBusinessPublisher { - return match ($this->postPlatform->platform) { + return match ($this->post->platform) { SocialPlatform::LinkedIn => app(LinkedInPublisher::class), SocialPlatform::LinkedInPage => app(LinkedInPagePublisher::class), SocialPlatform::X => app(XPublisher::class), @@ -575,20 +574,20 @@ private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublis private function updatePostStatus(): void { - app(FinalizePostPublication::class)->handle($this->postPlatform->post); + app(FinalizePostPublication::class)->handle($this->post); } public function failed(?Throwable $exception): void { Log::error('PublishToSocialPlatform job failed permanently', [ - 'post_platform_id' => $this->postPlatform->id, - 'platform' => $this->postPlatform->platform->value, + 'post_id' => $this->post->id, + 'platform' => $this->post->platform->value, 'error' => $exception?->getMessage(), ]); - $this->postPlatform->refresh(); + $this->post->refresh(); - if ($this->postPlatform->status->isClosed() || $this->postPlatform->isWaitingForLimitRetry()) { + if ($this->post->publish_status->isClosed() || $this->post->isPublicationWaitingForLimitRetry()) { return; } diff --git a/app/Jobs/ReconcileGoogleBusinessPost.php b/app/Jobs/ReconcileGoogleBusinessPost.php index 6a57db1ac2..ef7a863f49 100644 --- a/app/Jobs/ReconcileGoogleBusinessPost.php +++ b/app/Jobs/ReconcileGoogleBusinessPost.php @@ -6,13 +6,13 @@ use App\Actions\Post\FinalizePostPublication; use App\Enums\GoogleBusiness\LocalPostState; -use App\Enums\PostPlatform\Status; -use App\Events\PostPlatformStatusUpdated; +use App\Enums\Post\PublishStatus as Status; +use App\Events\PostStatusUpdated; use App\Exceptions\PlatformUnavailableException; use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\GoogleBusinessPublishException; use App\Exceptions\TokenExpiredException; -use App\Models\PostPlatform; +use App\Models\Post; use App\Models\SocialAccount; use App\Services\Social\ConnectionVerifier; use App\Services\Social\GoogleBusinessPublisher; @@ -47,25 +47,25 @@ class ReconcileGoogleBusinessPost implements ShouldBeUnique, ShouldQueue */ public const REVIEW_CEILING_HOURS = 24; - public function __construct(public PostPlatform $postPlatform) + public function __construct(public Post $post) { - $this->onQueue($postPlatform->platform->queue()); + $this->onQueue($post->platform->queue()); } public function uniqueId(): string { - return $this->postPlatform->id; + return $this->post->id; } public function handle(): void { - $this->postPlatform->refresh(); + $this->post->refresh(); - if ($this->postPlatform->status !== Status::PendingReview || blank($this->postPlatform->platform_post_id)) { + if ($this->post->publish_status !== Status::PendingReview || blank($this->post->platform_post_id)) { return; } - $account = $this->postPlatform->socialAccount; + $account = $this->post->socialAccount; if (! $account instanceof SocialAccount) { $this->giveUp(__('posts.errors.account_disconnected'), [ @@ -94,26 +94,26 @@ public function handle(): void } $state = LocalPostState::fromApi(data_get($remote, 'state')); - $platformUrl = (string) (data_get($remote, 'searchUrl') ?: $this->postPlatform->platform_url); + $platformUrl = (string) (data_get($remote, 'searchUrl') ?: $this->post->platform_url); if ($state->isLive()) { - $this->postPlatform->markAsPublished((string) $this->postPlatform->platform_post_id, $platformUrl); + $this->post->markPublicationPublished((string) $this->post->platform_post_id, $platformUrl); } elseif ($state->isRejected()) { - $this->postPlatform->markAsRejected( - (string) $this->postPlatform->platform_post_id, + $this->post->markPublicationRejected( + (string) $this->post->platform_post_id, $platformUrl, __('posts.errors.rejected_in_review'), ['provider_state' => $state->value], ); } elseif ($this->reviewExpired()) { - $this->postPlatform->markAsRejected( - (string) $this->postPlatform->platform_post_id, + $this->post->markPublicationRejected( + (string) $this->post->platform_post_id, $platformUrl, __('posts.errors.review_unconfirmed'), ['category' => 'review_unconfirmed', 'provider_state' => $state->value], ); } else { - $this->postPlatform->update([ + $this->post->writePublication([ 'platform_url' => $platformUrl, 'last_reconciled_at' => now(), ]); @@ -121,15 +121,15 @@ public function handle(): void return; } - $this->postPlatform->update(['last_reconciled_at' => now()]); + $this->post->writePublication(['last_reconciled_at' => now()]); $this->settle(); } public function failed(?Throwable $exception): void { - $this->postPlatform->refresh(); + $this->post->refresh(); - if ($this->postPlatform->status !== Status::PendingReview) { + if ($this->post->publish_status !== Status::PendingReview) { return; } @@ -151,7 +151,7 @@ private function fetchRemote(SocialAccount $account): array return app(GoogleBusinessPublisher::class)->fetchLocalPost( $account, - (string) $this->postPlatform->platform_post_id, + (string) $this->post->platform_post_id, ); } @@ -223,7 +223,7 @@ private function deferOrGiveUp(string $errorMessage): void return; } - $this->postPlatform->update(['last_reconciled_at' => now()]); + $this->post->writePublication(['last_reconciled_at' => now()]); } /** @@ -231,19 +231,19 @@ private function deferOrGiveUp(string $errorMessage): void */ private function giveUp(string $errorMessage, array $errorContext = []): void { - $this->postPlatform->markAsRejected( - (string) $this->postPlatform->platform_post_id, - $this->postPlatform->platform_url, + $this->post->markPublicationRejected( + (string) $this->post->platform_post_id, + $this->post->platform_url, $errorMessage, $errorContext, ); - $this->postPlatform->update(['last_reconciled_at' => now()]); + $this->post->writePublication(['last_reconciled_at' => now()]); $this->settle(); } private function reviewExpired(): bool { - $submittedAt = $this->postPlatform->submitted_at; + $submittedAt = $this->post->submitted_at; return $submittedAt instanceof CarbonInterface && $submittedAt->copy()->addHours(self::REVIEW_CEILING_HOURS)->isPast(); @@ -251,8 +251,8 @@ private function reviewExpired(): bool private function settle(): void { - app(GoogleBusinessDerivativeCleaner::class)->cleanup($this->postPlatform->id); - app(FinalizePostPublication::class)->handle($this->postPlatform->post); - PostPlatformStatusUpdated::dispatch($this->postPlatform->fresh()); + app(GoogleBusinessDerivativeCleaner::class)->cleanup($this->post); + app(FinalizePostPublication::class)->handle($this->post); + PostStatusUpdated::dispatch($this->post->fresh()); } } diff --git a/app/Jobs/Repurpose/PollRepurposeSource.php b/app/Jobs/Repurpose/PollRepurposeSource.php index a182392914..7f452da4c1 100644 --- a/app/Jobs/Repurpose/PollRepurposeSource.php +++ b/app/Jobs/Repurpose/PollRepurposeSource.php @@ -10,7 +10,7 @@ use App\Enums\Repurpose\SourceFormat; use App\Enums\Repurpose\Status; use App\Exceptions\Repurpose\SourceFetchException; -use App\Models\PostPlatform; +use App\Models\Post; use App\Models\Repurpose; use App\Models\RepurposeItem; use App\Models\SocialAccount; @@ -20,7 +20,6 @@ use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; @@ -223,11 +222,10 @@ private function idsPublishedByTryPost(array $media): array return []; } - return PostPlatform::query() + return Post::query() ->whereIn('platform_post_id', $ids) - ->whereHas('post', fn (Builder $query) => $query - ->createdInTryPost() - ->where('workspace_id', $this->account->workspace_id)) + ->createdInTryPost() + ->where('workspace_id', $this->account->workspace_id) ->pluck('platform_post_id') ->all(); } diff --git a/app/Jobs/VerifyUpcomingPostConnections.php b/app/Jobs/VerifyUpcomingPostConnections.php index c64f53e68b..4318a33522 100644 --- a/app/Jobs/VerifyUpcomingPostConnections.php +++ b/app/Jobs/VerifyUpcomingPostConnections.php @@ -5,12 +5,12 @@ namespace App\Jobs; use App\Enums\Notification\Type; -use App\Enums\PostPlatform\Status as PostPlatformStatus; +use App\Enums\Post\PublishStatus; use App\Enums\SocialAccount\Status as SocialAccountStatus; use App\Exceptions\PlatformUnavailableException; use App\Exceptions\TokenExpiredException; use App\Mail\PostAtRisk; -use App\Models\PostPlatform; +use App\Models\Post; use App\Models\SocialAccount; use App\Models\User; use App\Models\Workspace; @@ -76,15 +76,15 @@ public function handle(ConnectionVerifier $verifier): void return; } - $postPlatforms = $this->atRiskPostPlatforms(); + $posts = $this->atRiskPosts(); - if ($postPlatforms->isEmpty()) { + if ($posts->isEmpty()) { return; } $atRisk = new Collection; - foreach ($postPlatforms->groupBy('social_account_id') as $group) { + foreach ($posts->groupBy('social_account_id') as $group) { $account = $group->first()->socialAccount; if (! $account) { @@ -94,15 +94,6 @@ public function handle(ConnectionVerifier $verifier): void continue; } - $group = $group->filter(fn (PostPlatform $pp) => $pp->post !== null); - - if ($group->isEmpty()) { - // Same race as above, but for the post: every row in this - // batch was hard-deleted between the main query and its - // eager-loaded relation resolving. - continue; - } - $account = SocialAccount::query()->with('workspace')->find($account->id); if (! $account) { @@ -116,7 +107,7 @@ public function handle(ConnectionVerifier $verifier): void // Already known broken from an earlier run — don't re-verify, // just warn about the posts that entered the window since then. - $atRisk->push(['account' => $account, 'postPlatforms' => $group]); + $atRisk->push(['account' => $account, 'posts' => $group]); continue; } @@ -127,7 +118,7 @@ public function handle(ConnectionVerifier $verifier): void continue; } - $nearestScheduledAt = $group->min(fn (PostPlatform $pp) => $pp->post->scheduled_at); + $nearestScheduledAt = $group->min(fn (Post $post) => $post->scheduled_at); if ($nearestScheduledAt->isAfter(now()->addMinutes(self::VERIFY_LEAD_MINUTES))) { // Not close enough to publishing yet — defer the actual API @@ -192,7 +183,7 @@ public function handle(ConnectionVerifier $verifier): void continue; } - $atRisk->push(['account' => $account, 'postPlatforms' => $group]); + $atRisk->push(['account' => $account, 'posts' => $group]); } catch (Exception $e) { Log::error('Failed to verify social account connection for upcoming-post check', [ 'account_id' => $account->id, @@ -217,7 +208,7 @@ public function handle(ConnectionVerifier $verifier): void return; } - // Conditioned on the same "unwarned" window atRiskPostPlatforms() selected + // Conditioned on the same "unwarned" window atRiskPosts() selected // on, so a concurrent run that already claimed some or all of these // exact rows (the ShouldBeUnique lock's TTL matches the schedule // cadence, so two instances can briefly overlap if a run takes @@ -225,9 +216,9 @@ public function handle(ConnectionVerifier $verifier): void // the gap between reading which rows are still claimable and // stamping them — without it, two overlapping runs could both read // "unclaimed" for the same row before either writes. - $warnedIds = $atRisk->flatMap(fn (array $group) => $group['postPlatforms']->pluck('id')); + $warnedIds = $atRisk->flatMap(fn (array $group) => $group['posts']->pluck('id')); $claimedIds = DB::transaction(function () use ($warnedIds) { - $claimableIds = PostPlatform::whereIn('id', $warnedIds) + $claimableIds = Post::whereIn('id', $warnedIds) ->where(function ($query) { $query->whereNull('connection_warning_sent_at') ->orWhere('connection_warning_sent_at', '<', now()->subDay()); @@ -244,7 +235,7 @@ public function handle(ConnectionVerifier $verifier): void return $claimableIds; } - PostPlatform::whereIn('id', $claimableIds)->update(['connection_warning_sent_at' => now()]); + Post::whereIn('id', $claimableIds)->toBase()->update(['connection_warning_sent_at' => now()]); return $claimableIds; }, attempts: 3); @@ -255,24 +246,24 @@ public function handle(ConnectionVerifier $verifier): void // (Pre-existing trade-off, not introduced by this transaction: a // crash between the DB transaction above and notifyOwner() below - // loses the warning for 24h, until atRiskPostPlatforms()'s re-check window.) + // loses the warning for 24h, until atRiskPosts()'s re-check window.) // A concurrent run may have already claimed some (not all) of these // rows between when $atRisk was built and the claim above — narrow // the notification down to what THIS run actually claimed, so the // email never lists an account/post pair another run is already // notifying about. $claimedIds is a non-empty subset of $warnedIds, - // which is exactly the union of every group's post_platform ids, so + // which is exactly the union of every group's post ids, so // at least one group is guaranteed to survive this filter. $atRisk = $atRisk ->map(function (array $group) use ($claimedIds) { - $group['postPlatforms'] = $group['postPlatforms']->filter( - fn (PostPlatform $pp) => $claimedIds->containsStrict($pp->id) + $group['posts'] = $group['posts']->filter( + fn (Post $post) => $claimedIds->containsStrict($post->id) ); return $group; }) - ->filter(fn (array $group) => $group['postPlatforms']->isNotEmpty()); + ->filter(fn (array $group) => $group['posts']->isNotEmpty()); $this->notifyOwner($owner, $workspace, $atRisk); } @@ -280,11 +271,11 @@ public function handle(ConnectionVerifier $verifier): void /** * Whether we've already sent a PostAtRisk notification covering this * account within the cooldown window — checked against any of its - * post_platforms, not just the ones in the current batch. + * posts, not just the ones in the current batch. */ private function recentlyWarnedAbout(SocialAccount $account): bool { - return PostPlatform::query() + return Post::query() ->where('social_account_id', $account->id) ->where('connection_warning_sent_at', '>=', now()->subMinutes(self::RENOTIFY_COOLDOWN_MINUTES)) ->exists(); @@ -301,45 +292,40 @@ private function recentlyDisconnected(SocialAccount $account): bool } /** - * @return Collection + * @return Collection */ - private function atRiskPostPlatforms(): Collection + private function atRiskPosts(): Collection { - return PostPlatform::query() - ->where('status', PostPlatformStatus::Pending) - ->enabled() // PublishPost only iterates enabled platforms — an at-risk warning for a disabled one would be a false positive. + return Post::query() + ->where('workspace_id', $this->workspaceId) + ->scheduled() + ->whereBetween('scheduled_at', [now(), now()->addHour()]) + ->where('publish_status', PublishStatus::Pending) ->whereHas('socialAccount') ->where(function ($query) { $query->whereNull('connection_warning_sent_at') ->orWhere('connection_warning_sent_at', '<', now()->subDay()); }) - ->whereHas('post', function ($query) { - $query->where('workspace_id', $this->workspaceId) - ->scheduled() - ->whereBetween('scheduled_at', [now(), now()->addHour()]); - }) // socialAccount.workspace is eager-loaded even though this job // never reads it directly — SocialAccountObserver::syncUsage() // (fired by the ->update() calls below via markAsTokenExpired()) // reads $account->workspace. Without this eager load every // account in the batch triggers its own extra query there. - ->with(['socialAccount.workspace', 'post']) + ->with(['socialAccount.workspace']) ->get(); } /** - * @param Collection}> $atRisk + * @param Collection}> $atRisk */ private function notifyOwner(User $owner, Workspace $workspace, Collection $atRisk): void { - $postPlatforms = $atRisk->flatMap(fn (array $group) => $group['postPlatforms']); - $postCount = $postPlatforms->pluck('post_id')->unique()->count(); - $postPlatformIds = $postPlatforms->pluck('id')->all(); + $postIds = $atRisk->flatMap(fn (array $group) => $group['posts'])->pluck('id')->unique()->values()->all(); SendNotification::dispatch( user: $owner, type: Type::PostAtRisk, - mailable: new PostAtRisk($workspace, $postPlatformIds, $postCount, $owner), + mailable: new PostAtRisk($workspace, $postIds, count($postIds), $owner), ); } } diff --git a/app/Mail/PostAtRisk.php b/app/Mail/PostAtRisk.php index a6e8dc852d..c9dde06a1f 100644 --- a/app/Mail/PostAtRisk.php +++ b/app/Mail/PostAtRisk.php @@ -4,7 +4,7 @@ namespace App\Mail; -use App\Models\PostPlatform; +use App\Models\Post; use App\Models\User; use App\Models\Workspace; use App\Support\Mail\RecipientTime; @@ -30,7 +30,7 @@ class PostAtRisk extends Mailable implements ShouldQueue /** * Only the workspace and the recipient (real Eloquent models, reduced to - * lightweight identifiers by SerializesModels), the post_platform IDs, and the count + * lightweight identifiers by SerializesModels), the post IDs, and the count * observed at dispatch time are carried on the queue payload. The rows * themselves are rehydrated in atRiskGroups() so the queued job's * serialized size stays small and the account/post details reflect @@ -41,11 +41,11 @@ class PostAtRisk extends Mailable implements ShouldQueue * between dispatch and send, the subject may differ from the number of * rows actually listed in the body — an acceptable rare edge case. * - * @param array $postPlatformIds + * @param array $postIds */ public function __construct( public Workspace $workspace, - public array $postPlatformIds, + public array $postIds, public int $count, ?User $recipient = null, ) { @@ -81,7 +81,7 @@ public function content(): Content } /** - * @return Collection, postCount: int, times: string}> + * @return Collection, postCount: int, times: string}> */ private function atRiskGroups(): Collection { @@ -89,12 +89,12 @@ private function atRiskGroups(): Collection return $this->atRiskGroups; } - $postPlatforms = PostPlatform::query() - ->with(['socialAccount', 'post']) - ->whereIn('id', $this->postPlatformIds) + $posts = Post::query() + ->with('socialAccount') + ->whereIn('id', $this->postIds) ->get(); - return $this->atRiskGroups = $postPlatforms->groupBy('social_account_id') + return $this->atRiskGroups = $posts->groupBy('social_account_id') // The account can be null if it was hard-deleted between dispatch // and send — nothing meaningful to render for it (no platform, no // handle), so it's dropped rather than crashing the render. @@ -102,10 +102,10 @@ private function atRiskGroups(): Collection ->map(function (Collection $group) { return [ 'account' => $group->first()->socialAccount, - 'postPlatforms' => $group, + 'posts' => $group, 'postCount' => $group->count(), - 'times' => $group->sortBy(fn ($pp) => $pp->post->scheduled_at) - ->map(fn ($pp) => RecipientTime::clock($pp->post->scheduled_at, $this->recipient())) + 'times' => $group->sortBy(fn (Post $post) => $post->scheduled_at) + ->map(fn (Post $post) => RecipientTime::clock($post->scheduled_at, $this->recipient())) ->implode(', '), ]; })->values(); diff --git a/app/Mail/PostNoteAdded.php b/app/Mail/PostNoteAdded.php index 3edb2abd6d..b882b61ba5 100644 --- a/app/Mail/PostNoteAdded.php +++ b/app/Mail/PostNoteAdded.php @@ -44,11 +44,7 @@ public function content(): Content 'workspaceName' => $post->workspace->name, 'noteBody' => (string) $this->note->body, 'postExcerpt' => PostExcerpt::from($post->content, 200), - 'channels' => $post->postPlatforms() - ->with('socialAccount') - ->enabled() - ->get() - ->all(), + 'channels' => $post->loadMissing('socialAccount')->hasDestination() ? [$post] : [], 'url' => route('app.posts.edit', ['post' => $post, 'comment' => $this->note->id]), ], ); diff --git a/app/Mail/PostPublishFailed.php b/app/Mail/PostPublishFailed.php index a8bb790066..037ad566c1 100644 --- a/app/Mail/PostPublishFailed.php +++ b/app/Mail/PostPublishFailed.php @@ -4,7 +4,7 @@ namespace App\Mail; -use App\Enums\PostPlatform\Status; +use App\Enums\Post\PublishStatus; use App\Models\Post; use App\Support\Mail\PostPreview; use Illuminate\Bus\Queueable; @@ -31,11 +31,8 @@ public function envelope(): Envelope public function content(): Content { - $postPlatform = $this->post->postPlatforms() - ->with('socialAccount') - ->enabled() - ->where('status', Status::Failed) - ->first(); + $this->post->loadMissing('socialAccount'); + $failed = in_array($this->post->publish_status, [PublishStatus::Failed, PublishStatus::Rejected], true) && $this->post->hasDestination(); return new Content( view: 'mail.post-publish-failed', @@ -43,10 +40,10 @@ public function content(): Content 'title' => __('mail.post_publish_failed.title'), 'previewText' => __('mail.post_publish_failed.preview'), 'workspaceName' => $this->post->workspace->name, - 'publication' => $postPlatform ? [ - 'accountName' => $postPlatform->display_name, - 'platform' => $postPlatform->platform, - 'error' => $postPlatform->error_message, + 'publication' => $failed ? [ + 'accountName' => $this->post->display_name, + 'platform' => $this->post->platform, + 'error' => $this->post->error_message, ] : null, 'postPreview' => PostPreview::from($this->post), 'url' => route('app.posts.index', ['tab' => 'sent', 'post' => $this->post->id]), diff --git a/app/Mail/PostPublished.php b/app/Mail/PostPublished.php index 04d43c862e..d521ff0dc6 100644 --- a/app/Mail/PostPublished.php +++ b/app/Mail/PostPublished.php @@ -4,6 +4,7 @@ namespace App\Mail; +use App\Enums\Post\PublishStatus; use App\Models\Post; use App\Support\Mail\PostPreview; use Illuminate\Bus\Queueable; @@ -30,11 +31,8 @@ public function envelope(): Envelope public function content(): Content { - $postPlatform = $this->post->postPlatforms() - ->with('socialAccount') - ->enabled() - ->published() - ->first(); + $this->post->loadMissing('socialAccount'); + $published = $this->post->publish_status === PublishStatus::Published; return new Content( view: 'mail.post-published', @@ -42,12 +40,12 @@ public function content(): Content 'title' => __('mail.post_published.title'), 'previewText' => __('mail.post_published.preview'), 'workspaceName' => $this->post->workspace->name, - 'publication' => $postPlatform ? [ - 'accountName' => $postPlatform->display_name, - 'platform' => $postPlatform->platform, + 'publication' => $published ? [ + 'accountName' => $this->post->display_name, + 'platform' => $this->post->platform, ] : null, 'postPreview' => PostPreview::from($this->post), - 'publishedUrl' => $postPlatform?->platform_url, + 'publishedUrl' => $published ? $this->post->platform_url : null, 'url' => route('app.posts.edit', $this->post), ], ); diff --git a/app/Mcp/Tools/Post/ApprovePostTool.php b/app/Mcp/Tools/Post/ApprovePostTool.php index 67b1b55e49..9d01730b23 100644 --- a/app/Mcp/Tools/Post/ApprovePostTool.php +++ b/app/Mcp/Tools/Post/ApprovePostTool.php @@ -53,7 +53,7 @@ public function handle(Request $request): Response|ResponseFactory /** @var Post $approved */ $approved = data_get($result, 'post'); - $approved->load(['postPlatforms.socialAccount', 'labels']); + $approved->load(['socialAccount', 'labels']); return Response::structured((new PostResource($approved))->resolve()); } diff --git a/app/Mcp/Tools/Post/AttachMediaFromUploadTool.php b/app/Mcp/Tools/Post/AttachMediaFromUploadTool.php index a1107d1002..30a365b999 100644 --- a/app/Mcp/Tools/Post/AttachMediaFromUploadTool.php +++ b/app/Mcp/Tools/Post/AttachMediaFromUploadTool.php @@ -19,7 +19,7 @@ use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Tool; -#[Description('Attach a file uploaded with request-media-upload-tool to a post. The upload_token is the value request-media-upload-tool returned; the file is resolved by that token within the current workspace, then appended after the post media (to reorder or remove media, call update-post-tool with the media ids in the new order). The file type must be one the post channel accepts (allowed_media_types in list-content-types-tool). Posts that are publishing, published, partially_published or failed cannot change; a scheduled post edited by a member who needs approval goes back to pending_approval. Size, video duration, GIF and MOV caps per content_type (see list-content-types-tool) are checked when the post is scheduled or published, not here. Media is added to the post itself; to give a thread reply its own media, pass the upload_token in meta.thread_replies with update-post-tool.')] +#[Description('Attach a file uploaded with request-media-upload-tool to a post. The upload_token is the value request-media-upload-tool returned; the file is resolved by that token within the current workspace, then appended after the post media (to reorder or remove media, call update-post-tool with the media ids in the new order). The file type must be one the post channel accepts (allowed_media_types in list-content-types-tool). Posts that are publishing, published or failed cannot change; a scheduled post edited by a member who needs approval goes back to pending_approval. Size, video duration, GIF and MOV caps per content_type (see list-content-types-tool) are checked when the post is scheduled or published, not here. Media is added to the post itself; to give a thread reply its own media, pass the upload_token in meta.thread_replies with update-post-tool.')] class AttachMediaFromUploadTool extends Tool { use AuthorizesMcpTool; @@ -59,7 +59,7 @@ public function handle(Request $request): Response|ResponseFactory return Response::error(__('posts.errors.queue_busy')); } - $post->refresh()->load(['postPlatforms.socialAccount', 'labels']); + $post->refresh()->load(['socialAccount', 'labels']); return Response::structured([ 'post' => (new PostResource($post))->resolve(), diff --git a/app/Mcp/Tools/Post/AttachMediaFromUrlTool.php b/app/Mcp/Tools/Post/AttachMediaFromUrlTool.php index 7ffbf50786..ca8a606ebf 100644 --- a/app/Mcp/Tools/Post/AttachMediaFromUrlTool.php +++ b/app/Mcp/Tools/Post/AttachMediaFromUrlTool.php @@ -47,7 +47,7 @@ public function handle(Request $request): Response|ResponseFactory return Response::error(__('posts.errors.queue_busy')); } - $post->refresh()->load(['postPlatforms.socialAccount', 'labels']); + $post->refresh()->load(['socialAccount', 'labels']); return Response::structured([ 'post' => (new PostResource($post))->resolve(), diff --git a/app/Mcp/Tools/Post/ClearPostRecurrenceTool.php b/app/Mcp/Tools/Post/ClearPostRecurrenceTool.php index 5f538edbb2..20584ee799 100644 --- a/app/Mcp/Tools/Post/ClearPostRecurrenceTool.php +++ b/app/Mcp/Tools/Post/ClearPostRecurrenceTool.php @@ -45,7 +45,7 @@ public function handle(Request $request): Response|ResponseFactory UpdatePostRecurrence::execute($post, null); - $post = Post::query()->with(['postPlatforms.socialAccount', 'labels'])->findOrFail($post->id); + $post = Post::query()->with(['socialAccount', 'labels'])->findOrFail($post->id); return Response::structured((new PostResource($post))->resolve()); } diff --git a/app/Mcp/Tools/Post/CreatePostTool.php b/app/Mcp/Tools/Post/CreatePostTool.php index e4574f0639..ad568bfcce 100644 --- a/app/Mcp/Tools/Post/CreatePostTool.php +++ b/app/Mcp/Tools/Post/CreatePostTool.php @@ -21,6 +21,7 @@ use App\Support\PostStatusRules; use App\Support\Requests\Post\PostRequestRules; use Illuminate\Contracts\JsonSchema\JsonSchema; +use Illuminate\Support\Arr; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\ResponseFactory; @@ -63,13 +64,13 @@ public function handle(Request $request): Response|ResponseFactory 'queue_slot' => $validated['queue_slot'] ?? null, 'label_ids' => $validated['label_ids'] ?? [], 'created_via' => CreatedVia::Mcp, - 'destinations' => [$validated['platforms'][0]], + 'destinations' => [Arr::only($validated, ['social_account_id', 'content_type', 'meta'])], ])->sole(); } catch (QueueBusyException) { return Response::error(__('posts.errors.queue_busy')); } - $post->load(['postPlatforms.socialAccount', 'labels']); + $post->load(['socialAccount', 'labels']); return Response::structured((new PostResource($post))->resolve()); } @@ -88,14 +89,9 @@ public function schema(JsonSchema $schema): array 'label_ids' => $schema->array() ->items($schema->string()) ->description('Workspace label IDs to attach to the post.'), - 'platforms' => $schema->array() - ->items($schema->object(fn ($p) => [ - 'social_account_id' => $p->string()->required()->description('UUID of the connected social account.'), - 'content_type' => $p->string()->description('Format for this platform (e.g. linkedin_post, x_post, instagram_feed). Optional. Omitted, it is chosen as the web composer does: on pinterest a video makes pinterest_video_pin, several images pinterest_carousel, else pinterest_pin; on tiktok images only make tiktok_photo, else tiktok_video; every other network takes its default_content_type (list-content-types-tool). A type sent explicitly is validated against the media and refused when they do not match.'), - 'meta' => $p->object()->description(PostPlatformMetaRules::documentation()), - ])) - ->required() - ->description('Exactly one social account. Use create-posts-tool for multiple accounts.'), + 'social_account_id' => $schema->string()->required()->description('UUID of the connected social account (list-social-accounts-tool). Use create-posts-tool for several accounts.'), + 'content_type' => $schema->string()->description('Format for this account (e.g. linkedin_post, x_post, instagram_feed). Optional. Omitted, it is chosen as the web composer does: on pinterest a video makes pinterest_video_pin, several images pinterest_carousel, else pinterest_pin; on tiktok images only make tiktok_photo, else tiktok_video; every other network takes its default_content_type (list-content-types-tool). A type sent explicitly is validated against the media and refused when they do not match.'), + 'meta' => $schema->object()->description(PostPlatformMetaRules::documentation()), ]; } } diff --git a/app/Mcp/Tools/Post/CreatePostsTool.php b/app/Mcp/Tools/Post/CreatePostsTool.php index 5cc50282e5..f8218ef360 100644 --- a/app/Mcp/Tools/Post/CreatePostsTool.php +++ b/app/Mcp/Tools/Post/CreatePostsTool.php @@ -52,7 +52,7 @@ public function handle(Request $request): Response|ResponseFactory return Response::structured([ 'posts' => $posts->map(function (Post $post): array { - $post->load(['postPlatforms.socialAccount', 'labels']); + $post->load(['socialAccount', 'labels']); return (new PostResource($post))->resolve(); })->all(), diff --git a/app/Mcp/Tools/Post/DeletePostTool.php b/app/Mcp/Tools/Post/DeletePostTool.php index 299f516eca..7d7d21043c 100644 --- a/app/Mcp/Tools/Post/DeletePostTool.php +++ b/app/Mcp/Tools/Post/DeletePostTool.php @@ -16,7 +16,7 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive; #[IsDestructive] -#[Description('Delete a post from TryPost permanently; this cannot be undone. A post is never removed from the network where it was published. Posts that are publishing, published, partially_published or failed cannot be changed or deleted. A member who needs approval may delete only posts they wrote, or requests they made that are still pending approval.')] +#[Description('Delete a post from TryPost permanently; this cannot be undone. A post is never removed from the network where it was published. Posts that are publishing, published or failed cannot be changed or deleted. A member who needs approval may delete only posts they wrote, or requests they made that are still pending approval.')] class DeletePostTool extends Tool { use AuthorizesMcpTool; diff --git a/app/Mcp/Tools/Post/GetPostMetricsTool.php b/app/Mcp/Tools/Post/GetPostMetricsTool.php index 5571b00022..ce28a55286 100644 --- a/app/Mcp/Tools/Post/GetPostMetricsTool.php +++ b/app/Mcp/Tools/Post/GetPostMetricsTool.php @@ -16,7 +16,7 @@ use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; #[IsReadOnly] -#[Description('Read all latest saved analytics for a TryPost post across its published platforms, including reactions, comments, saves, reach, views, and watch time where supported. Values may lag the provider until the next analytics job. Returns "unsupported" for excluded or unpublished platforms.')] +#[Description('Read the latest saved analytics of a published TryPost post, including reactions, comments, saves, reach, views, and watch time where supported. Values may lag the provider until the next analytics job. metrics is "unsupported" for an unpublished post or a network without analytics.')] class GetPostMetricsTool extends Tool { use AuthorizesMcpTool; @@ -31,7 +31,7 @@ public function handle(Request $request): Response|ResponseFactory $post = $workspace ? Post::where('workspace_id', $workspace->id) - ->with(['postPlatforms.socialAccount']) + ->with(['socialAccount']) ->find(data_get($validated, 'post_id')) : null; @@ -43,10 +43,7 @@ public function handle(Request $request): Response|ResponseFactory return $denied; } - return Response::structured([ - 'post_id' => $post->id, - 'platforms' => app(ReadPublicationAnalytics::class)->forPost($post)->all(), - ]); + return Response::structured(app(ReadPublicationAnalytics::class)->forPost($post)); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/Post/GetPostTool.php b/app/Mcp/Tools/Post/GetPostTool.php index ba37909412..3c43057e9c 100644 --- a/app/Mcp/Tools/Post/GetPostTool.php +++ b/app/Mcp/Tools/Post/GetPostTool.php @@ -33,7 +33,7 @@ public function handle(Request $request): Response|ResponseFactory } $post = Post::where('workspace_id', $workspace->id) - ->with(['postPlatforms.socialAccount', 'labels']) + ->with(['socialAccount', 'labels']) ->find(data_get($validated, 'post_id')); if (! $post) { diff --git a/app/Mcp/Tools/Post/ListPostsTool.php b/app/Mcp/Tools/Post/ListPostsTool.php index a06cc7e8fe..9c136bd045 100644 --- a/app/Mcp/Tools/Post/ListPostsTool.php +++ b/app/Mcp/Tools/Post/ListPostsTool.php @@ -58,7 +58,7 @@ public function handle(Request $request): Response|ResponseFactory RequestIds::uuidList(collect((array) data_get($validated, 'labels'))), filter_var(data_get($validated, 'untagged'), FILTER_VALIDATE_BOOLEAN), ) - ->with(['postPlatforms.socialAccount', 'user', 'approvalRequestedBy', 'approver', 'labels']); + ->with(['socialAccount', 'user', 'approvalRequestedBy', 'approver', 'labels']); $query = match (data_get($validated, 'status')) { Status::Draft->value => $query->draft(), diff --git a/app/Mcp/Tools/Post/PreviewPostTool.php b/app/Mcp/Tools/Post/PreviewPostTool.php index 92f098745d..db4f4fcc26 100644 --- a/app/Mcp/Tools/Post/PreviewPostTool.php +++ b/app/Mcp/Tools/Post/PreviewPostTool.php @@ -16,7 +16,7 @@ use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; #[IsReadOnly] -#[Description('Preview how a post will be sent to each enabled platform without publishing: the exact text each network gets (X links defused when that is on), next to the original content, with length stats. Use it to check a text before scheduling.')] +#[Description('Preview how a post will be sent to its channel without publishing: the exact text the network gets (X links defused when that is on), next to the original content, with length stats. Use it to check a text before scheduling.')] class PreviewPostTool extends Tool { use AuthorizesMcpTool; @@ -31,7 +31,7 @@ public function handle(Request $request): Response|ResponseFactory $post = $workspace ? Post::where('workspace_id', $workspace->id) - ->with(['postPlatforms.socialAccount']) + ->with(['socialAccount']) ->find(data_get($validated, 'post_id')) : null; diff --git a/app/Mcp/Tools/Post/PublishPostTool.php b/app/Mcp/Tools/Post/PublishPostTool.php index b5261aa709..0b586842d8 100644 --- a/app/Mcp/Tools/Post/PublishPostTool.php +++ b/app/Mcp/Tools/Post/PublishPostTool.php @@ -23,7 +23,7 @@ use Laravel\Mcp\Server\Tools\Annotations\IsDestructive; #[IsDestructive] -#[Description('Publish a post now, schedule it at a custom time (scheduled_at) or put it in the channel queue (queue). Works on drafts and on scheduled posts. Use update-post-tool first to change its content, content_type or meta. It is validated like a scheduled post: text limits, required meta, the media rules of its content_type (see list-content-types-tool) and thread replies; a failure names the platform and field, and nothing is published. Posts that are publishing, published, partially_published or failed cannot be changed or deleted. When the acting member needs approval in this workspace, the post is stored with status pending_approval instead and waits for approve-post-tool. Before a post can be scheduled or published it needs: TikTok meta.privacy_level (get-tiktok-creator-info-tool), Pinterest meta.board_id (list-pinterest-boards-tool), Discord meta.channel_id (list-discord-channels-tool), Google Business events and offers meta.event (title and dates), YouTube a title (meta.title, or the first line of the text); list-content-types-tool lists them per platform as required_meta. A scheduled or published post also needs text or media.')] +#[Description('Publish a post now, schedule it at a custom time (scheduled_at) or put it in the channel queue (queue). Works on drafts and on scheduled posts. Use update-post-tool first to change its content, content_type or meta. It is validated like a scheduled post: text limits, required meta, the media rules of its content_type (see list-content-types-tool) and thread replies; a failure names the platform and field, and nothing is published. Posts that are publishing, published or failed cannot be changed or deleted. When the acting member needs approval in this workspace, the post is stored with status pending_approval instead and waits for approve-post-tool. Before a post can be scheduled or published it needs: TikTok meta.privacy_level (get-tiktok-creator-info-tool), Pinterest meta.board_id (list-pinterest-boards-tool), Discord meta.channel_id (list-discord-channels-tool), Google Business events and offers meta.event (title and dates), YouTube a title (meta.title, or the first line of the text); list-content-types-tool lists them per platform as required_meta. A scheduled or published post also needs text or media.')] class PublishPostTool extends Tool { use AuthorizesMcpTool; @@ -45,7 +45,7 @@ public function handle(Request $request): Response|ResponseFactory return $denied; } - if (! $post->postPlatforms()->enabled()->exists()) { + if (! $post->hasChannel()) { return Response::error(__('posts.errors.no_social_account')); } @@ -73,7 +73,7 @@ public function handle(Request $request): Response|ResponseFactory /** @var Post $updated */ $updated = data_get($result, 'post'); - $updated->load(['postPlatforms.socialAccount', 'labels']); + $updated->load(['socialAccount', 'labels']); return Response::structured((new PostResource($updated))->resolve()); } diff --git a/app/Mcp/Tools/Post/RejectPostTool.php b/app/Mcp/Tools/Post/RejectPostTool.php index edc0bb562b..8eb4024dca 100644 --- a/app/Mcp/Tools/Post/RejectPostTool.php +++ b/app/Mcp/Tools/Post/RejectPostTool.php @@ -46,7 +46,7 @@ public function handle(Request $request): Response|ResponseFactory return Response::error(__('posts.errors.queue_busy')); } - $rejected->load(['postPlatforms.socialAccount', 'labels']); + $rejected->load(['socialAccount', 'labels']); return Response::structured((new PostResource($rejected))->resolve()); } diff --git a/app/Mcp/Tools/Post/SetPostRecurrenceTool.php b/app/Mcp/Tools/Post/SetPostRecurrenceTool.php index 3e8e2bd8d7..4c9aa25e1c 100644 --- a/app/Mcp/Tools/Post/SetPostRecurrenceTool.php +++ b/app/Mcp/Tools/Post/SetPostRecurrenceTool.php @@ -10,7 +10,6 @@ use App\Http\Resources\Api\PostResource; use App\Mcp\Concerns\AuthorizesMcpTool; use App\Models\Post; -use App\Models\PostPlatform; use App\Models\Workspace; use App\Support\PostingSchedule; use App\Support\Timezone; @@ -74,11 +73,10 @@ public function handle(Request $request): Response|ResponseFactory $frequency = RecurrenceFrequency::from((string) $request->get('frequency')); $steps = (int) $request->get('interval') * (int) $request->get('times'); $ceiling = CarbonImmutable::parse(PostingSchedule::MAX_INSTANT, 'UTC'); - $tooFar = $post->postPlatforms()->enabled()->with('socialAccount')->get() - ->contains(fn (PostPlatform $target): bool => $frequency->advance( - $post->scheduled_at->toImmutable()->setTimezone(Timezone::normalize($target->socialAccount?->timezone)), - $steps, - )->greaterThan($ceiling)); + $tooFar = $post->hasDestination() && $frequency->advance( + $post->scheduled_at->toImmutable()->setTimezone(Timezone::normalize($post->socialAccount?->timezone)), + $steps, + )->greaterThan($ceiling); if ($tooFar) { $validator->errors()->add('times', __('posts.recurrence.errors.too_far')); @@ -87,7 +85,7 @@ public function handle(Request $request): Response|ResponseFactory UpdatePostRecurrence::execute($post, $validated); - $post = Post::query()->with(['postPlatforms.socialAccount', 'labels'])->findOrFail($post->id); + $post = Post::query()->with(['socialAccount', 'labels'])->findOrFail($post->id); return Response::structured((new PostResource($post))->resolve()); } diff --git a/app/Mcp/Tools/Post/UpdatePostTool.php b/app/Mcp/Tools/Post/UpdatePostTool.php index 47c37d108f..e75bafadd8 100644 --- a/app/Mcp/Tools/Post/UpdatePostTool.php +++ b/app/Mcp/Tools/Post/UpdatePostTool.php @@ -27,7 +27,7 @@ use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Tool; -#[Description('Update one post for its existing social account. Caption, media, content_type, meta (platform settings, merged with the stored ones), status, schedule, queue and labels may change; content_type and meta are top-level fields because a post has exactly one social account, which is fixed. When the acting member needs approval in this workspace, a scheduled, queued or publish-now update is stored with status pending_approval instead. meta is merged with the stored settings: send null for a key to clear it; thread_replies replaces the whole list. To place the post in one specific free slot use move-post-to-slot-tool. Posts that are publishing, published, partially_published or failed cannot be changed or deleted. To publish a thread on X, Bluesky or Mastodon, put the follow-up posts in meta.thread_replies: up to 24 replies published under the post, each {text, media} with up to 4 media items given by url, id or upload_token, e.g. [{"text": "2/ ..."}, {"text": "3/ ...", "media": [{"url": "https://..."}]}]. Each reply must fit the text limit of the account and follows the media rules of a post on that network. Before a post can be scheduled or published it needs: TikTok meta.privacy_level (get-tiktok-creator-info-tool), Pinterest meta.board_id (list-pinterest-boards-tool), Discord meta.channel_id (list-discord-channels-tool), Google Business events and offers meta.event (title and dates), YouTube a title (meta.title, or the first line of the text); list-content-types-tool lists them per platform as required_meta. A scheduled or published post also needs text or media.')] +#[Description('Update one post for its existing social account. Caption, media, content_type, meta (platform settings, merged with the stored ones), status, schedule, queue and labels may change; content_type and meta are top-level fields because a post has exactly one social account, which is fixed. When the acting member needs approval in this workspace, a scheduled, queued or publish-now update is stored with status pending_approval instead. meta is merged with the stored settings: send null for a key to clear it; thread_replies replaces the whole list. To place the post in one specific free slot use move-post-to-slot-tool. Posts that are publishing, published or failed cannot be changed or deleted. To publish a thread on X, Bluesky or Mastodon, put the follow-up posts in meta.thread_replies: up to 24 replies published under the post, each {text, media} with up to 4 media items given by url, id or upload_token, e.g. [{"text": "2/ ..."}, {"text": "3/ ...", "media": [{"url": "https://..."}]}]. Each reply must fit the text limit of the account and follows the media rules of a post on that network. Before a post can be scheduled or published it needs: TikTok meta.privacy_level (get-tiktok-creator-info-tool), Pinterest meta.board_id (list-pinterest-boards-tool), Discord meta.channel_id (list-discord-channels-tool), Google Business events and offers meta.event (title and dates), YouTube a title (meta.title, or the first line of the text); list-content-types-tool lists them per platform as required_meta. A scheduled or published post also needs text or media.')] class UpdatePostTool extends Tool { use AuthorizesMcpTool; @@ -50,7 +50,7 @@ public function handle(Request $request): Response|ResponseFactory return $denied; } - $input = PostRequestRules::updateInput($post, $request->all()); + $input = $request->all(); $validated = Validator::make($input, PostRequestRules::update($workspace, $post, $input), PostRequestRules::messages(), PostRequestRules::attributes()) ->after(fn (ValidatorContract $validator) => PostRequestRules::afterUpdate($validator, $post, $input)) @@ -70,7 +70,7 @@ public function handle(Request $request): Response|ResponseFactory /** @var Post $updated */ $updated = data_get($result, 'post'); - $updated->load(['postPlatforms.socialAccount', 'labels']); + $updated->load(['socialAccount', 'labels']); return Response::structured((new PostResource($updated))->resolve()); } diff --git a/app/Mcp/Tools/SocialAccount/MovePostToSlotTool.php b/app/Mcp/Tools/SocialAccount/MovePostToSlotTool.php index 9bb6c49ad7..ee35eb2493 100644 --- a/app/Mcp/Tools/SocialAccount/MovePostToSlotTool.php +++ b/app/Mcp/Tools/SocialAccount/MovePostToSlotTool.php @@ -54,7 +54,7 @@ public function handle(Request $request): Response|ResponseFactory return Response::error(__('posts.errors.queue_busy')); } - $post = Post::query()->with(['postPlatforms.socialAccount', 'labels'])->findOrFail(data_get($validated, 'post_id')); + $post = Post::query()->with(['socialAccount', 'labels'])->findOrFail(data_get($validated, 'post_id')); return Response::structured((new PostResource($post))->resolve()); } diff --git a/app/Mcp/Tools/Webhook/CreateWebhookTool.php b/app/Mcp/Tools/Webhook/CreateWebhookTool.php index 536143407e..12d8266f73 100644 --- a/app/Mcp/Tools/Webhook/CreateWebhookTool.php +++ b/app/Mcp/Tools/Webhook/CreateWebhookTool.php @@ -58,7 +58,7 @@ public function schema(JsonSchema $schema): array 'events' => $schema->array() ->items($schema->string()->enum(array_column(EventType::cases(), 'value'))) ->required() - ->description('At least one event to subscribe to: post.created, post.scheduled, post.unscheduled, post.published, post.partially_published, post.failed, post.deleted.'), + ->description('At least one event to subscribe to: post.created, post.scheduled, post.unscheduled, post.published, post.failed, post.deleted.'), ]; } } diff --git a/app/Models/AnalyticsPublication.php b/app/Models/AnalyticsPublication.php index 521a148b27..d0ac3b401e 100644 --- a/app/Models/AnalyticsPublication.php +++ b/app/Models/AnalyticsPublication.php @@ -25,7 +25,7 @@ class AnalyticsPublication extends Model 'workspace_id', 'social_account_id', 'social_account_key', - 'post_platform_id', + 'post_id', 'post_dismissed_at', 'network', 'platform_user_id', @@ -77,9 +77,9 @@ public function socialAccount(): BelongsTo return $this->belongsTo(SocialAccount::class); } - public function postPlatform(): BelongsTo + public function post(): BelongsTo { - return $this->belongsTo(PostPlatform::class); + return $this->belongsTo(Post::class); } public function dailySnapshots(): HasMany diff --git a/app/Models/Post.php b/app/Models/Post.php index 4b0d70739b..4c0f19b709 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -9,10 +9,12 @@ use App\Enums\Media\Type; use App\Enums\Post\CreatedVia; use App\Enums\Post\Origin; +use App\Enums\Post\PublishStatus; use App\Enums\Post\QueuePosition; use App\Enums\Post\RecurrenceFrequency; use App\Enums\Post\ScheduleMode; use App\Enums\Post\Status as PostStatus; +use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; use App\Observers\PostObserver; use App\Support\Media\MediaCopyBatch; @@ -27,8 +29,10 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Storage; #[ObservedBy([PostObserver::class])] class Post extends Model @@ -61,6 +65,34 @@ class Post extends Model 'approved_by', 'approved_at', 'approval_queue_position', + 'platform', + 'content_type', + 'platform_name', + 'platform_username', + 'platform_avatar', + 'meta', + 'platform_url', + 'error_message', + 'error_context', + 'thread_reply_ids', + 'submitted_at', + 'last_reconciled_at', + 'connection_warning_sent_at', + 'retry_at', + 'publication_updated_at', + ]; + + /** + * @var list + */ + protected $hidden = [ + 'error_context', + 'legacy_target_id', + 'scheduled_before_media_checks', + 'last_reconciled_at', + 'connection_warning_sent_at', + 'publication_updated_at', + 'platform_avatar', ]; /** @@ -68,6 +100,7 @@ class Post extends Model */ protected $attributes = [ 'origin' => Origin::DEFAULT->value, + 'publish_status' => PublishStatus::DEFAULT->value, ]; protected function casts(): array @@ -88,6 +121,18 @@ protected function casts(): array 'approval_requested_at' => 'datetime', 'approved_at' => 'datetime', 'approval_queue_position' => QueuePosition::class, + 'publish_status' => PublishStatus::class, + 'platform' => Platform::class, + 'content_type' => ContentType::class, + 'meta' => 'array', + 'error_context' => 'array', + 'thread_reply_ids' => 'array', + 'submitted_at' => 'datetime', + 'last_reconciled_at' => 'datetime', + 'connection_warning_sent_at' => 'datetime', + 'retry_at' => 'datetime', + 'publication_updated_at' => 'datetime', + 'scheduled_before_media_checks' => 'boolean', ]; } @@ -137,9 +182,14 @@ public function ownedMedia(): HasMany return $this->hasMany(Media::class, 'post_id')->orderBy('order'); } - public function postPlatforms(): HasMany + public function socialAccount(): BelongsTo + { + return $this->belongsTo(SocialAccount::class); + } + + public function analyticsPublication(): HasOne { - return $this->hasMany(PostPlatform::class)->orderBy('id'); + return $this->hasOne(AnalyticsPublication::class); } public function notes(): HasMany @@ -166,13 +216,13 @@ public function scopeScheduled(Builder $query): Builder } /** - * Scheduled posts with an enabled destination on the given channel, upcoming after `$after`. + * Scheduled posts on the given channel, upcoming after `$after`. */ public function scopeScheduledOn(Builder $query, string $channelId, CarbonInterface $after): Builder { return $query->scheduled() ->where('scheduled_at', '>', $after) - ->whereHas('postPlatforms', fn (Builder $platforms) => $platforms->enabled()->where('social_account_id', $channelId)); + ->where('posts.social_account_id', $channelId); } /** @@ -188,7 +238,7 @@ public function scopeOccupyingSlotsOn(Builder $query, string $channelId, CarbonI { return $query->holdingSlot() ->where('scheduled_at', '>', $after) - ->whereHas('postPlatforms', fn (Builder $platforms) => $platforms->enabled()->where('social_account_id', $channelId)); + ->where('posts.social_account_id', $channelId); } public function scopePendingQueueRequestsOn(Builder $query, string $channelId, CarbonInterface $after): Builder @@ -196,7 +246,7 @@ public function scopePendingQueueRequestsOn(Builder $query, string $channelId, C return $query->pendingApproval() ->where('schedule_mode', ScheduleMode::Queue) ->where('scheduled_at', '>', $after) - ->whereHas('postPlatforms', fn (Builder $platforms) => $platforms->enabled()->where('social_account_id', $channelId)); + ->where('posts.social_account_id', $channelId); } public function scopeQueuedOn(Builder $query, string $channelId, CarbonInterface $after): Builder @@ -261,7 +311,7 @@ public function scopeImported(Builder $query): Builder public function scopePublished(Builder $query): Builder { - return $query->whereIn('status', [PostStatus::Published, PostStatus::PartiallyPublished]); + return $query->where('status', PostStatus::Published); } public function scopeFailed(Builder $query): Builder @@ -280,16 +330,36 @@ public function scopeLatestAttempt(Builder $query): Builder } /** - * Posts with an enabled destination on any of the channels; no filter when $channelIds is null. + * Posts on any of the channels; no filter when $channelIds is null. * * @param list|null $channelIds */ public function scopeOnChannels(Builder $query, ?array $channelIds): Builder { - return $query->when($channelIds !== null, fn (Builder $filtered): Builder => $filtered->whereHas( - 'postPlatforms', - fn (Builder $platforms): Builder => $platforms->enabled()->whereIn('social_account_id', $channelIds), - )); + return $query->when($channelIds !== null, fn (Builder $filtered): Builder => $filtered->whereIn('posts.social_account_id', $channelIds)); + } + + /** + * Posts the network confirmed as published. + */ + public function scopePublicationPublished(Builder $query): Builder + { + return $query->where('posts.publish_status', PublishStatus::Published); + } + + /** + * Posts a network refused for a limit whose next attempt is due. + */ + public function scopeDueForLimitRetry(Builder $query): Builder + { + return $query->where('posts.publish_status', PublishStatus::Retrying) + ->whereNotNull('posts.retry_at') + ->where('posts.retry_at', '<=', now()); + } + + public function scopeIncludedInAnalytics(Builder $query): Builder + { + return $query->whereIn('posts.platform', Platform::analyticsValues()); } /** @@ -313,21 +383,192 @@ public function markAsPublished(): void { $this->update([ 'status' => PostStatus::Published, - 'published_at' => now(), + 'published_at' => $this->published_at ?? now(), ]); } - public function markAsPartiallyPublished(): void + public function markAsFailed(): void { - $this->update([ - 'status' => PostStatus::PartiallyPublished, - 'published_at' => now(), + $this->update(['status' => PostStatus::Failed]); + } + + /** + * The post has a destination: a channel, or the snapshot of one that was deleted. + */ + public function hasDestination(): bool + { + return $this->platform !== null; + } + + /** + * The post's channel still exists. + */ + public function hasChannel(): bool + { + return $this->social_account_id !== null; + } + + /** + * Display name, falling back to the snapshot when the account was deleted. + */ + public function getDisplayNameAttribute(): ?string + { + return $this->socialAccount?->accountDisplayName() ?? $this->platform_name ?? $this->platform?->label(); + } + + /** + * Username, falling back to the snapshot when the account was deleted. + */ + public function getDisplayUsernameAttribute(): ?string + { + return $this->socialAccount?->username ?? $this->platform_username; + } + + /** + * Avatar URL, falling back to the snapshot when the account was deleted. + */ + public function getDisplayAvatarAttribute(): ?string + { + if ($this->socialAccount?->avatar_url) { + return $this->socialAccount->avatar_url; + } + + return $this->platform_avatar ? Storage::url($this->platform_avatar) : null; + } + + /** + * "Facebook Page (@handle)" for emails. Username first, then display name + * (live account or the snapshot). When neither is set, just the platform + * name, never "(@)". Empty for a draft without a channel. + */ + public function notificationLabel(): string + { + if ($this->platform === null) { + return ''; + } + + $identifier = $this->display_username + ?: $this->socialAccount?->display_name + ?: $this->platform_name; + + if (! filled($identifier) || $identifier === $this->platform->label()) { + return $this->platform->label(); + } + + return "{$this->platform->label()} (@{$identifier})"; + } + + /** + * Whether the publisher attaches the link preview card for the first URL. + * False only when the user dropped the card in the composer. + */ + public function attachesLinkPreview(): bool + { + return data_get($this->meta, 'link_preview') !== false; + } + + public function markPublicationPublishing(): void + { + $this->writePublication(['publish_status' => PublishStatus::Publishing, 'retry_at' => null]); + } + + /** + * The network refused the publish for a limit: the post waits, not + * failed, until the scheduler picks it up at `retry_at`. + * + * @param array $errorContext + */ + public function markPublicationWaitingForLimitRetry(CarbonInterface $retryAt, string $errorMessage, array $errorContext): void + { + $this->writePublication([ + 'publish_status' => PublishStatus::Retrying, + 'retry_at' => $retryAt, + 'error_message' => $errorMessage, + 'error_context' => $errorContext, ]); } - public function markAsFailed(): void + public function isPublicationWaitingForLimitRetry(): bool { - $this->update(['status' => PostStatus::Failed]); + return $this->publish_status === PublishStatus::Retrying && $this->retry_at?->isFuture() === true; + } + + public function markPublicationPublished(string $platformPostId, ?string $platformUrl = null): void + { + $now = now(); + + $this->writePublication([ + 'publish_status' => PublishStatus::Published, + 'platform_post_id' => $platformPostId, + 'platform_url' => $platformUrl, + 'published_at' => $now, + 'retry_at' => null, + 'error_message' => null, + 'error_context' => null, + ]); + + $this->socialAccount?->update(['last_used_at' => $now]); + } + + /** + * The provider accepted the post but has not finished reviewing it. It is + * neither published nor failed until the review settles. + */ + public function markPublicationPendingReview(string $platformPostId, ?string $platformUrl = null): void + { + $this->writePublication([ + 'publish_status' => PublishStatus::PendingReview, + 'platform_post_id' => $platformPostId, + 'platform_url' => $platformUrl, + 'submitted_at' => $this->submitted_at ?? now(), + 'retry_at' => null, + 'error_message' => null, + 'error_context' => null, + ]); + } + + /** + * The provider accepted the post and then refused it in review. Unlike a + * failure, the remote row exists, so its id and URL are kept for support. + * + * @param array|null $errorContext + */ + public function markPublicationRejected(string $platformPostId, ?string $platformUrl, string $errorMessage, ?array $errorContext = null): void + { + $this->writePublication([ + 'publish_status' => PublishStatus::Rejected, + 'platform_post_id' => $platformPostId, + 'platform_url' => $platformUrl, + 'retry_at' => null, + 'error_message' => $errorMessage, + 'error_context' => $errorContext, + ]); + } + + /** + * @param array|null $errorContext + */ + public function markPublicationFailed(string $errorMessage, ?array $errorContext = null): void + { + $this->writePublication([ + 'publish_status' => PublishStatus::Failed, + 'retry_at' => null, + 'error_message' => $errorMessage, + 'error_context' => $errorContext, + 'platform_post_id' => null, + 'platform_url' => null, + ]); + } + + /** + * Writes the publication fields with their own clock, so a publish attempt + * never moves `updated_at` and a label or note never moves the clock. + * + * @param array $attributes + */ + public function writePublication(array $attributes): void + { + static::withoutTimestamps(fn () => $this->forceFill([...$attributes, 'publication_updated_at' => now()])->save()); } /** @@ -374,21 +615,13 @@ public static function withoutRecurrence(): array } /** - * MediaTypes accepted by this post — the intersection of what every - * enabled platform allows. With no platform enabled, accept anything. + * MediaTypes accepted by this post's channel. Without a channel, accept anything. * * @return array */ public function allowedMediaTypes(): array { - $platforms = $this->postPlatforms() - ->enabled() - ->with('socialAccount') - ->get() - ->pluck('socialAccount.platform') - ->filter(); - - return self::allowedMediaTypesFor($platforms); + return self::allowedMediaTypesFor(collect([$this->socialAccount?->platform])->filter()); } /** diff --git a/app/Models/PostPlatform.php b/app/Models/PostPlatform.php deleted file mode 100644 index 8180e64c94..0000000000 --- a/app/Models/PostPlatform.php +++ /dev/null @@ -1,265 +0,0 @@ - */ - use HasFactory, HasUuids; - - protected $fillable = [ - 'post_id', - 'social_account_id', - 'enabled', - 'platform', - 'platform_name', - 'platform_username', - 'platform_avatar', - 'content_type', - 'status', - 'platform_post_id', - 'platform_url', - 'error_message', - 'error_context', - 'thread_reply_ids', - 'published_at', - 'submitted_at', - 'last_reconciled_at', - 'meta', - 'connection_warning_sent_at', - 'retry_at', - ]; - - protected function casts(): array - { - return [ - 'enabled' => 'boolean', - 'scheduled_before_media_checks' => 'boolean', - 'platform' => SocialPlatform::class, - 'content_type' => ContentType::class, - 'status' => Status::class, - 'published_at' => 'datetime', - 'submitted_at' => 'datetime', - 'last_reconciled_at' => 'datetime', - 'meta' => 'array', - 'error_context' => 'array', - 'thread_reply_ids' => 'array', - 'connection_warning_sent_at' => 'datetime', - 'retry_at' => 'datetime', - ]; - } - - public function post(): BelongsTo - { - return $this->belongsTo(Post::class); - } - - public function socialAccount(): BelongsTo - { - return $this->belongsTo(SocialAccount::class); - } - - public function analyticsPublication(): HasOne - { - return $this->hasOne(AnalyticsPublication::class); - } - - /** - * Only platforms still enabled for publishing — disabled ones are - * excluded from PublishPost, so anything else that mirrors publish - * eligibility (previews, validation, proactive checks) must too. - */ - public function scopeEnabled(Builder $query): Builder - { - return $query->where('post_platforms.enabled', true); - } - - public function scopeDisabled(Builder $query): Builder - { - return $query->where('post_platforms.enabled', false); - } - - public function scopePublished(Builder $query): Builder - { - return $query->where('post_platforms.status', Status::Published); - } - - /** - * Targets a network refused for a limit whose next attempt is due. - */ - public function scopeDueForLimitRetry(Builder $query): Builder - { - return $query->where('post_platforms.status', Status::Retrying) - ->whereNotNull('post_platforms.retry_at') - ->where('post_platforms.retry_at', '<=', now()); - } - - public function scopeIncludedInAnalytics(Builder $query): Builder - { - return $query->whereIn('post_platforms.platform', SocialPlatform::analyticsValues()); - } - - /** - * Get display name, falling back to snapshot if account was deleted. - */ - public function getDisplayNameAttribute(): string - { - return $this->socialAccount?->accountDisplayName() ?? $this->platform_name ?? $this->platform->label(); - } - - /** - * Get username, falling back to snapshot if account was deleted. - */ - public function getDisplayUsernameAttribute(): ?string - { - return $this->socialAccount?->username ?? $this->platform_username; - } - - /** - * "Facebook Page (@handle)" for emails. - * Username first, then display name (live account or the snapshot - * kept on this row). When neither is set — or the account is gone - * and there is no snapshot — just the platform name, never "(@)". - */ - public function notificationLabel(): string - { - $identifier = $this->display_username - ?: $this->socialAccount?->display_name - ?: $this->platform_name; - - if (! filled($identifier) || $identifier === $this->platform->label()) { - return $this->platform->label(); - } - - return "{$this->platform->label()} (@{$identifier})"; - } - - /** - * Get avatar URL, falling back to snapshot if account was deleted. - */ - public function getDisplayAvatarAttribute(): ?string - { - if ($this->socialAccount?->avatar_url) { - return $this->socialAccount->avatar_url; - } - - return $this->platform_avatar ? Storage::url($this->platform_avatar) : null; - } - - /** - * Whether the publisher attaches the link preview card for the first URL. - * False only when the user dropped the card in the composer. - */ - public function attachesLinkPreview(): bool - { - return data_get($this->meta, 'link_preview') !== false; - } - - public function markAsPublishing(): void - { - $this->update(['status' => Status::Publishing, 'retry_at' => null]); - } - - /** - * The network refused the publish for a limit: the target waits, not - * failed, until the scheduler picks it up at `retry_at`. - * - * @param array $errorContext - */ - public function markAsWaitingForLimitRetry(CarbonInterface $retryAt, string $errorMessage, array $errorContext): void - { - $this->update([ - 'status' => Status::Retrying, - 'retry_at' => $retryAt, - 'error_message' => $errorMessage, - 'error_context' => $errorContext, - ]); - } - - public function isWaitingForLimitRetry(): bool - { - return $this->status === Status::Retrying && $this->retry_at?->isFuture() === true; - } - - public function markAsPublished(string $platformPostId, ?string $platformUrl = null): void - { - $now = now(); - - $this->update([ - 'status' => Status::Published, - 'platform_post_id' => $platformPostId, - 'platform_url' => $platformUrl, - 'published_at' => $now, - 'retry_at' => null, - 'error_message' => null, - 'error_context' => null, - ]); - - $this->socialAccount?->update(['last_used_at' => $now]); - } - - /** - * The provider accepted the post but has not finished reviewing it. It is - * neither published nor failed until the review settles. - */ - public function markAsPendingReview(string $platformPostId, ?string $platformUrl = null): void - { - $this->update([ - 'status' => Status::PendingReview, - 'platform_post_id' => $platformPostId, - 'platform_url' => $platformUrl, - 'submitted_at' => $this->submitted_at ?? now(), - 'retry_at' => null, - 'error_message' => null, - 'error_context' => null, - ]); - } - - /** - * The provider accepted the post and then refused it in review. Unlike a - * failure, the remote row exists, so its id and URL are kept for support. - * - * @param array|null $errorContext - */ - public function markAsRejected(string $platformPostId, ?string $platformUrl, string $errorMessage, ?array $errorContext = null): void - { - $this->update([ - 'status' => Status::Rejected, - 'platform_post_id' => $platformPostId, - 'platform_url' => $platformUrl, - 'retry_at' => null, - 'error_message' => $errorMessage, - 'error_context' => $errorContext, - ]); - } - - public function markAsFailed(string $errorMessage, ?array $errorContext = null): void - { - $this->update([ - 'status' => Status::Failed, - 'retry_at' => null, - 'error_message' => $errorMessage, - 'error_context' => $errorContext, - 'platform_post_id' => null, - 'platform_url' => null, - ]); - } -} diff --git a/app/Models/SocialAccount.php b/app/Models/SocialAccount.php index e6cd9b5a52..e74c1edfe1 100644 --- a/app/Models/SocialAccount.php +++ b/app/Models/SocialAccount.php @@ -8,8 +8,8 @@ use App\Actions\SocialAccount\ApplyChannelDefaults; use App\Casts\PostingScheduleCast; use App\Enums\Notification\Type; +use App\Enums\Post\PublishStatus; use App\Enums\PostPlatform\ContentType; -use App\Enums\PostPlatform\Status as PostPlatformStatus; use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\SocialAccount\Status; use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException; @@ -263,26 +263,28 @@ private static function realignUnpublishedTargets(self $account, SocialPlatform return; } - $awaitingPublish = [PostPlatformStatus::Pending, PostPlatformStatus::Retrying]; + $awaitingPublish = [PublishStatus::Pending, PublishStatus::Retrying]; $supported = array_values(array_map( fn (ContentType $contentType): string => $contentType->value, ContentType::forPlatform($to), )); - $account->postPlatforms() - ->whereIn('status', $awaitingPublish) + $account->posts() + ->whereIn('publish_status', $awaitingPublish) ->whereNotIn('content_type', $supported) - ->update(['content_type' => ContentType::defaultFor($to)->value]); + ->toBase() + ->update(['content_type' => ContentType::defaultFor($to)->value, 'publication_updated_at' => now()]); - $account->postPlatforms() - ->whereIn('status', $awaitingPublish) - ->update(['platform' => $to->value]); + $account->posts() + ->whereIn('publish_status', $awaitingPublish) + ->toBase() + ->update(['platform' => $to->value, 'publication_updated_at' => now()]); } - public function postPlatforms(): HasMany + public function posts(): HasMany { - return $this->hasMany(PostPlatform::class); + return $this->hasMany(Post::class); } public function analyticsSyncStates(): HasMany @@ -371,7 +373,7 @@ public function accountDisplayName(): string } /** - * The channel identity a new PostPlatform keeps, so the post still shows + * The channel identity a new post keeps, so the post still shows * who it went to after the account is renamed or disconnected. * * @return array{platform_name: string, platform_username: ?string, platform_avatar: ?string} diff --git a/app/Observers/PostObserver.php b/app/Observers/PostObserver.php index a2b49b3448..c9321a4bd2 100644 --- a/app/Observers/PostObserver.php +++ b/app/Observers/PostObserver.php @@ -4,11 +4,19 @@ namespace App\Observers; +use App\Actions\Analytics\ResolveAnalyticsAccountKey; +use App\Dto\Analytics\TryPostPublicationIdentity; +use App\Enums\Post\PublishStatus; use App\Enums\Post\Status as PostStatus; use App\Events\PostCreated; use App\Events\PostStatusChanged; +use App\Jobs\Analytics\SyncTryPostPublication; +use App\Jobs\PostHog\SyncAccountPublishingActivity; use App\Models\Post; +use App\Services\Analytics\Collectors\Followers\FollowerCollectorFactory; +use App\Services\PostHogService; use Illuminate\Support\Facades\DB; +use Throwable; class PostObserver { @@ -35,6 +43,51 @@ public function saved(Post $post): void DB::afterCommit(fn () => PostStatusChanged::dispatch($post, $previousStatus)); } + public function updated(Post $post): void + { + if (! $post->wasChanged('publish_status') + || $post->publish_status !== PublishStatus::Published + || ! filled($post->platform_post_id)) { + return; + } + + $this->dispatchAnalyticsSync($post); + + if (! PostHogService::isEnabled()) { + return; + } + + $accountId = $post->loadMissing('workspace')->workspace?->account_id; + + if (! $accountId) { + return; + } + + SyncAccountPublishingActivity::dispatch((string) $accountId) + ->delay(now()->addSeconds(SyncAccountPublishingActivity::DEBOUNCE_SECONDS)) + ->afterCommit(); + } + + private function dispatchAnalyticsSync(Post $post): void + { + try { + $account = $post->loadMissing('socialAccount')->socialAccount; + + if (! $account || ! app(FollowerCollectorFactory::class)->supports($post->platform)) { + return; + } + + $identity = TryPostPublicationIdentity::fromAccount( + $account, + app(ResolveAnalyticsAccountKey::class)->for($account), + ); + + SyncTryPostPublication::dispatch($identity, $post->id)->afterCommit(); + } catch (Throwable $exception) { + report($exception); + } + } + private function previousStatus(Post $post): ?PostStatus { $previous = $post->getRawOriginal('status'); diff --git a/app/Observers/PostPlatformObserver.php b/app/Observers/PostPlatformObserver.php deleted file mode 100644 index 49fba26946..0000000000 --- a/app/Observers/PostPlatformObserver.php +++ /dev/null @@ -1,65 +0,0 @@ -wasChanged('status') - || $postPlatform->status !== Status::Published - || ! filled($postPlatform->platform_post_id)) { - return; - } - - $this->dispatchAnalyticsSync($postPlatform); - - if (! PostHogService::isEnabled()) { - return; - } - - $accountId = $postPlatform - ->loadMissing('post.workspace') - ->post?->workspace?->account_id; - - if (! $accountId) { - return; - } - - SyncAccountPublishingActivity::dispatch((string) $accountId) - ->delay(now()->addSeconds(SyncAccountPublishingActivity::DEBOUNCE_SECONDS)) - ->afterCommit(); - } - - private function dispatchAnalyticsSync(PostPlatform $postPlatform): void - { - try { - $account = $postPlatform->loadMissing('socialAccount')->socialAccount; - - if (! $account || ! app(FollowerCollectorFactory::class)->supports($postPlatform->platform)) { - return; - } - - $identity = TryPostPublicationIdentity::fromAccount( - $account, - app(ResolveAnalyticsAccountKey::class)->for($account), - ); - - SyncTryPostPublication::dispatch($identity, $postPlatform->id)->afterCommit(); - } catch (Throwable $exception) { - report($exception); - } - } -} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 1cae4e3364..67c7db6ec2 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -20,7 +20,6 @@ use App\Models\Plan; use App\Models\Post; use App\Models\PostNote; -use App\Models\PostPlatform; use App\Models\PostTemplate; use App\Models\Repurpose; use App\Models\RepurposeItem; @@ -123,7 +122,6 @@ protected function configureMorphMap(): void 'rssFeedCollection' => RssFeedCollection::class, 'rssFeedItem' => RssFeedItem::class, 'postComment' => PostNote::class, - 'postPlatform' => PostPlatform::class, 'postTemplate' => PostTemplate::class, 'socialAccount' => SocialAccount::class, 'subscription' => Subscription::class, diff --git a/app/Rules/ContentTypeCompatibleWithMedia.php b/app/Rules/ContentTypeCompatibleWithMedia.php index 2d9713d01a..7ca74c08fc 100644 --- a/app/Rules/ContentTypeCompatibleWithMedia.php +++ b/app/Rules/ContentTypeCompatibleWithMedia.php @@ -83,35 +83,24 @@ public static function assertStoredPostCompatible(Post $post): void } /** - * The per-platform entries to validate for a post update: each platform's - * effective content_type (resubmitted in this request, else its stored - * value), keyed by the error path the caller surfaces. When - * $requestPlatforms is null, the post's currently-enabled platforms are used, - * and with $media (media sent without a content type) a network whose type - * follows its media (ContentType::derivesFromMedia()) takes the type it decides. + * The content type an update would publish: the submitted one, else the + * one the new media derives (Pinterest, TikTok), else the stored one. * - * @param array|null $requestPlatforms * @param array|null $media * @return array */ - public static function entriesForUpdate(Post $post, ?array $requestPlatforms, ?array $media = null): array + public static function entriesForUpdate(Post $post, ?string $contentType, ?array $media = null): array { - if (is_array($requestPlatforms)) { - $stored = $post->postPlatforms()->get()->keyBy('id'); - - return collect($requestPlatforms)->map(fn ($platform, $index): array => [ - 'key' => "platforms.{$index}.content_type", - 'content_type' => data_get($platform, 'content_type') ?? $stored->get(data_get($platform, 'id'))?->content_type?->value, - ])->all(); + if (! $post->hasDestination()) { + return []; } - return $post->postPlatforms()->enabled()->get()->values() - ->map(fn ($postPlatform, $index): array => [ - 'key' => "platforms.{$index}.content_type", - 'content_type' => $media !== null && ContentType::derivesFromMedia($postPlatform->platform) - ? ContentType::forMedia($postPlatform->platform, $media)->value - : $postPlatform->content_type?->value, - ])->all(); + return [[ + 'key' => 'content_type', + 'content_type' => $contentType ?? ($media !== null && ContentType::derivesFromMedia($post->platform) + ? ContentType::forMedia($post->platform, $media)->value + : $post->content_type?->value), + ]]; } /** diff --git a/app/Rules/ContentTypeMatchesPlatform.php b/app/Rules/ContentTypeMatchesPlatform.php index fd0da853d8..94fc3c97f8 100644 --- a/app/Rules/ContentTypeMatchesPlatform.php +++ b/app/Rules/ContentTypeMatchesPlatform.php @@ -12,17 +12,21 @@ use Illuminate\Support\Str; /** - * Cross-validates platforms[].content_type against platforms[].social_account_id - * — ensures the chosen content_type is supported by the account's platform. - * - * Use on `platforms.*.content_type` rules in form requests / tools that accept - * a `platforms[]` array with `social_account_id` siblings. + * Ensures the chosen content_type is supported by the platform of the account + * it is sent with: a top-level `social_account_id` (pass its attribute name) + * or the sibling of each `destinations.*.content_type`. */ class ContentTypeMatchesPlatform implements DataAwareRule, ValidationRule { /** @var array */ private array $data = []; + /** + * `$accountAttribute` names the field holding the account id; without it + * the rule reads the sibling `social_account_id` of the content type. + */ + public function __construct(private ?string $accountAttribute = null) {} + /** * @param array $data */ @@ -35,10 +39,7 @@ public function setData(array $data): static public function validate(string $attribute, mixed $value, Closure $fail): void { - // attribute is e.g. "platforms.0.content_type" — peel off the leaf - // and look up the sibling social_account_id under the same parent. - $parentKey = Str::beforeLast($attribute, '.'); - $accountId = data_get($this->data, $parentKey.'.social_account_id'); + $accountId = data_get($this->data, $this->accountAttribute ?? Str::beforeLast($attribute, '.').'.social_account_id'); if (! $accountId || ! Str::isUuid((string) $accountId)) { return; diff --git a/app/Rules/ContentTypeMatchesPostChannel.php b/app/Rules/ContentTypeMatchesPostChannel.php new file mode 100644 index 0000000000..82d723f5b5 --- /dev/null +++ b/app/Rules/ContentTypeMatchesPostChannel.php @@ -0,0 +1,33 @@ +post->socialAccount; + + if (! $contentType || ! $account) { + return; + } + + if (! in_array($account->platform, $contentType->compatiblePlatforms(), true)) { + $fail(sprintf( + 'content_type "%s" is not compatible with the %s account.', + $contentType->value, + $account->platform->label(), + )); + } + } +} diff --git a/app/Rules/ContentTypeMatchesPostPlatform.php b/app/Rules/ContentTypeMatchesPostPlatform.php deleted file mode 100644 index e29bbb6e64..0000000000 --- a/app/Rules/ContentTypeMatchesPostPlatform.php +++ /dev/null @@ -1,58 +0,0 @@ - */ - private array $data = []; - - /** - * @param array $data - */ - public function setData(array $data): static - { - $this->data = $data; - - return $this; - } - - public function validate(string $attribute, mixed $value, Closure $fail): void - { - $parentKey = Str::beforeLast($attribute, '.'); - $postPlatformId = data_get($this->data, $parentKey.'.id'); - - if (! $postPlatformId || ! Str::isUuid((string) $postPlatformId)) { - return; - } - - $contentType = ContentType::tryFrom((string) $value); - $postPlatform = PostPlatform::with('socialAccount')->find($postPlatformId); - - if (! $contentType || ! $postPlatform || ! $postPlatform->socialAccount) { - return; - } - - if (! in_array($postPlatform->socialAccount->platform, $contentType->compatiblePlatforms(), true)) { - $fail(sprintf( - 'content_type "%s" is not compatible with the %s account.', - $contentType->value, - $postPlatform->socialAccount->platform->label(), - )); - } - } -} diff --git a/app/Services/Media/MediaOptimizer.php b/app/Services/Media/MediaOptimizer.php index e29c464513..15c8998002 100644 --- a/app/Services/Media/MediaOptimizer.php +++ b/app/Services/Media/MediaOptimizer.php @@ -114,49 +114,6 @@ public function maxWidthForPlatform(Platform $platform): ?int return is_int($maxWidth) ? $maxWidth : null; } - /** - * Center-crop an image to the given aspect ratio (width / height), the - * crop the Facebook and Instagram publishers made before TryPost 2.0. Only - * the one-off `posts:bake-aspect-ratio-crops` release step calls it. - * Returns path to a temp file (caller must clean up). - */ - public function cropToAspectRatio(string $filePath, float $ratio): string - { - $this->assertWithinMemoryBudget($filePath); - - $image = $this->manager->decodePath($filePath); - - $width = $image->width(); - $height = $image->height(); - $current = $width / $height; - - if (abs($current - $ratio) < 0.001) { - $tempFile = tempnam(sys_get_temp_dir(), 'media_crop_'); - copy($filePath, $tempFile); - - return $tempFile; - } - - if ($current > $ratio) { - $newWidth = (int) round($height * $ratio); - $newHeight = $height; - } else { - $newWidth = $width; - $newHeight = (int) round($width / $ratio); - } - - $offsetX = (int) round(($width - $newWidth) / 2); - $offsetY = (int) round(($height - $newHeight) / 2); - - $image->crop($newWidth, $newHeight, $offsetX, $offsetY); - - $tempFile = tempnam(sys_get_temp_dir(), 'media_crop_'); - $encoded = $image->encodeUsingMediaType('image/jpeg', quality: 100); - file_put_contents($tempFile, (string) $encoded); - - return $tempFile; - } - /** * Fit an image inside a width×height canvas without cropping: the image is * scaled to fit and centered, and the empty space is filled with a blurred, diff --git a/app/Services/Post/PostPreviewer.php b/app/Services/Post/PostPreviewer.php index 88e091f95a..e29b90ab91 100644 --- a/app/Services/Post/PostPreviewer.php +++ b/app/Services/Post/PostPreviewer.php @@ -6,40 +6,38 @@ use App\Enums\SocialAccount\Platform; use App\Models\Post; -use App\Models\PostPlatform; use App\Services\Social\ContentSanitizer; use App\Support\ThreadReplies; use App\Support\YouTubeDescription; use App\Support\YouTubeMetadata; -use Illuminate\Support\Collection; /** - * Renders per-platform previews of a post — applies the platform-specific - * `ContentSanitizer` rules without publishing. Used by the REST API - * preview endpoint and the MCP `PreviewPostTool` so the rendering rules - * stay in one place. + * Renders the preview of a post for its channel — applies the platform-specific + * `ContentSanitizer` rules without publishing. Used by the REST API preview + * endpoint and the MCP `PreviewPostTool` so the rendering rules stay in one + * place. */ class PostPreviewer { public function __construct(private readonly ContentSanitizer $sanitizer) {} /** + * Without a channel only the original text is returned. + * * @return array{ * post_id: string, * original_content: string, * original_length: int, - * platforms: array>}> - * }> + * platform?: string, + * content_type?: ?string, + * sanitized_content?: string, + * sanitized_length?: int, + * max_content_length?: int, + * truncated?: bool, + * title?: string, + * description?: string, + * description_length_bytes?: int, + * thread_replies?: list>}> * } */ public function forPost(Post $post): array @@ -50,49 +48,48 @@ public function forPost(Post $post): array 'post_id' => $post->id, 'original_content' => $original, 'original_length' => mb_strlen($original), - 'platforms' => $this->platformPreviews($post, $original)->all(), + ...$this->channelPreview($post, $original), ]; } /** - * @return Collection> + * @return array */ - private function platformPreviews(Post $post, string $original): Collection + private function channelPreview(Post $post, string $original): array { - return $post->postPlatforms - ->where('enabled', true) - ->values() - ->map(function (PostPlatform $pp) use ($original) { - $platform = $pp->socialAccount?->platform ?? $pp->platform; - $sanitized = $this->sanitizer->sanitize($original, $platform); + $platform = $post->socialAccount?->platform ?? $post->platform; - $preview = [ - 'post_platform_id' => $pp->id, - 'platform' => $platform->value, - 'content_type' => $pp->content_type?->value, - 'sanitized_content' => $sanitized, - 'sanitized_length' => mb_strlen($sanitized), - 'max_content_length' => $pp->socialAccount?->maxContentLength() ?? $platform->maxContentLength(), - 'truncated' => mb_strlen($sanitized) < mb_strlen($original), - ]; + if ($platform === null) { + return []; + } - if ($platform === Platform::YouTube) { - $description = YouTubeDescription::resolve($pp->meta, $sanitized); - $preview['title'] = YouTubeMetadata::title($pp->meta, $sanitized); - $preview['description'] = $description; - $preview['description_length_bytes'] = strlen($description); - } + $sanitized = $this->sanitizer->sanitize($original, $platform); - $replies = ThreadReplies::supports($platform) ? ThreadReplies::of($pp->meta) : []; + $preview = [ + 'platform' => $platform->value, + 'content_type' => $post->content_type?->value, + 'sanitized_content' => $sanitized, + 'sanitized_length' => mb_strlen($sanitized), + 'max_content_length' => $post->socialAccount?->maxContentLength() ?? $platform->maxContentLength(), + 'truncated' => mb_strlen($sanitized) < mb_strlen($original), + ]; + + if ($platform === Platform::YouTube) { + $description = YouTubeDescription::resolve($post->meta, $sanitized); + $preview['title'] = YouTubeMetadata::title($post->meta, $sanitized); + $preview['description'] = $description; + $preview['description_length_bytes'] = strlen($description); + } + + $replies = ThreadReplies::supports($platform) ? ThreadReplies::of($post->meta) : []; - if ($replies !== []) { - $preview['thread_replies'] = array_map(fn (array $reply): array => [ - 'text' => $this->sanitizer->sanitize($reply['text'], $platform), - 'media' => $reply['media'], - ], $replies); - } + if ($replies !== []) { + $preview['thread_replies'] = array_map(fn (array $reply): array => [ + 'text' => $this->sanitizer->sanitize($reply['text'], $platform), + 'media' => $reply['media'], + ], $replies); + } - return $preview; - }); + return $preview; } } diff --git a/app/Services/Social/AbstractLinkedInPublisher.php b/app/Services/Social/AbstractLinkedInPublisher.php index 3832881c27..c204d653ee 100644 --- a/app/Services/Social/AbstractLinkedInPublisher.php +++ b/app/Services/Social/AbstractLinkedInPublisher.php @@ -10,7 +10,7 @@ use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\LinkedInPublishException; use App\Exceptions\TokenExpiredException; -use App\Models\PostPlatform; +use App\Models\Post; use App\Models\SocialAccount; use App\Services\Http\SafeHttpFetcher; use App\Services\Media\MediaOptimizer; @@ -73,15 +73,15 @@ protected function postUrl(?string $postId): ?string return $postId ? "https://www.linkedin.com/feed/update/{$postId}" : null; } - public function publish(PostPlatform $postPlatform): array + public function publish(Post $post): array { - $this->validateContentLength($postPlatform); + $this->validateContentLength($post); - $content = $postPlatform->post->content - ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) + $content = $post->content + ? app(ContentSanitizer::class)->sanitize($post->content, $post->platform) : null; - $this->account = $postPlatform->socialAccount; + $this->account = $post->socialAccount; $this->hasRetried = false; if ($this->account->needsProactiveTokenRefresh()) { @@ -91,28 +91,28 @@ public function publish(PostPlatform $postPlatform): array $this->accessToken = $this->account->access_token; try { - return $this->dispatchByMedia($content, $postPlatform); + return $this->dispatchByMedia($content, $post); } catch (TokenExpiredException $e) { - return $this->retryWithRefresh($postPlatform, $content, $e); + return $this->retryWithRefresh($post, $content, $e); } } - private function dispatchByMedia(?string $content, PostPlatform $postPlatform): array + private function dispatchByMedia(?string $content, Post $post): array { - $media = $postPlatform->post->mediaItems; + $media = $post->mediaItems; if ($media->contains(fn ($item) => $item->isDocument())) { - return $this->publishDocument($content, $media, $this->resolveDocumentTitle($postPlatform)); + return $this->publishDocument($content, $media, $this->resolveDocumentTitle($post)); } if ($media->filter(fn ($item) => $item->isImage())->count() >= 2) { return $this->publishCarousel($content, $media); } - return $this->publishPost($content, $media, $postPlatform->attachesLinkPreview()); + return $this->publishPost($content, $media, $post->attachesLinkPreview()); } - private function retryWithRefresh(PostPlatform $postPlatform, ?string $content, TokenExpiredException $originalException): array + private function retryWithRefresh(Post $post, ?string $content, TokenExpiredException $originalException): array { if ($this->hasRetried) { throw $originalException; @@ -124,7 +124,7 @@ private function retryWithRefresh(PostPlatform $postPlatform, ?string $content, app(ConnectionVerifier::class)->refreshToken($this->account); $this->accessToken = $this->account->access_token; - return $this->dispatchByMedia($content, $postPlatform); + return $this->dispatchByMedia($content, $post); } catch (Throwable $e) { Log::error("{$this->label()} refresh failed during retry", [ 'account_id' => $this->account->id, @@ -359,15 +359,15 @@ private function createPost(array $payload, string $context): array * The title shown on a LinkedIn document (PDF carousel) post. Falls back to * the uploaded file name, then a generic label, so it's never empty. */ - private function resolveDocumentTitle(PostPlatform $postPlatform): string + private function resolveDocumentTitle(Post $post): string { - $title = data_get($postPlatform->meta, 'document_title'); + $title = data_get($post->meta, 'document_title'); if (filled($title)) { return (string) $title; } - return $postPlatform->post->mediaItems->first(fn ($media) => $media->isDocument())?->original_filename ?? 'Document'; + return $post->mediaItems->first(fn ($media) => $media->isDocument())?->original_filename ?? 'Document'; } private function uploadMedia($mediaItem): ?string diff --git a/app/Services/Social/BlueskyPublisher.php b/app/Services/Social/BlueskyPublisher.php index caa4bef604..a66ec88926 100644 --- a/app/Services/Social/BlueskyPublisher.php +++ b/app/Services/Social/BlueskyPublisher.php @@ -9,7 +9,7 @@ use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\BlueskyPublishException; use App\Exceptions\Social\ErrorCategory; -use App\Models\PostPlatform; +use App\Models\Post; use App\Models\SocialAccount; use App\Services\Http\SafeHttpFetcher; use App\Services\Media\MediaOptimizer; @@ -52,13 +52,13 @@ class BlueskyPublisher private const JOB_STATE_FAILED = 'JOB_STATE_FAILED'; - public function publish(PostPlatform $postPlatform): array + public function publish(Post $post): array { - $this->validateContentLength($postPlatform); + $this->validateContentLength($post); - $content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null; + $content = $post->content ? app(ContentSanitizer::class)->sanitize($post->content, $post->platform) : null; - $account = $postPlatform->socialAccount; + $account = $post->socialAccount; $service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service'); // Refresh token if needed @@ -66,12 +66,12 @@ public function publish(PostPlatform $postPlatform): array app(ConnectionVerifier::class)->refreshToken($account); } - $lookForLiveReply = ThreadProgress::rootHash($postPlatform->error_context) !== null; + $lookForLiveReply = ThreadProgress::rootHash($post->error_context) !== null; return $this->publishThread( - $postPlatform, - ThreadProgress::hash((string) $content, $postPlatform->post->mediaItems->map(fn (MediaItem $item): string => $item->id)->all()), - fn (): array => $this->publishRoot($postPlatform, $account, $service, $content), + $post, + ThreadProgress::hash((string) $content, $post->mediaItems->map(fn (MediaItem $item): string => $item->id)->all()), + fn (): array => $this->publishRoot($post, $account, $service, $content), function (string $text, Collection $media, array $parent, array $root) use ($account, $service, &$lookForLiveReply): array { $live = $lookForLiveReply ? $this->liveReply($account, $text, $parent) : null; $lookForLiveReply = false; @@ -123,16 +123,16 @@ private function liveReply(SocialAccount $account, string $text, array $parent): /** * @return array{id: string, url: string, uri: string, cid: string} */ - private function publishRoot(PostPlatform $postPlatform, SocialAccount $account, string $service, ?string $content): array + private function publishRoot(Post $post, SocialAccount $account, string $service, ?string $content): array { - $medias = $postPlatform->post->mediaItems; + $medias = $post->mediaItems; $embed = $this->mediaEmbed($account, $service, $medias); // No image or video embed, so a bare link can carry a preview card. // Bluesky does not hydrate cards server-side: the client must attach an // app.bsky.embed.external built from the page's OpenGraph metadata. - if ($embed === null && $medias->isEmpty() && $content !== null && $postPlatform->attachesLinkPreview()) { - $embed = $this->buildExternalEmbed($postPlatform->socialAccount, $service, $content); + if ($embed === null && $medias->isEmpty() && $content !== null && $post->attachesLinkPreview()) { + $embed = $this->buildExternalEmbed($post->socialAccount, $service, $content); } // Parse facets (links, mentions, hashtags) from text diff --git a/app/Services/Social/Concerns/HasSocialHttpClient.php b/app/Services/Social/Concerns/HasSocialHttpClient.php index c74daf965c..0f079a672d 100644 --- a/app/Services/Social/Concerns/HasSocialHttpClient.php +++ b/app/Services/Social/Concerns/HasSocialHttpClient.php @@ -5,7 +5,7 @@ namespace App\Services\Social\Concerns; use App\Exceptions\Social\ContentLimitException; -use App\Models\PostPlatform; +use App\Models\Post; use App\Services\Social\ContentSanitizer; use App\Services\Social\TokenRedactor; use App\Support\ThreadReplies; @@ -24,33 +24,33 @@ trait HasSocialHttpClient * hashtag cap is a save-time rule only, so a post stored before it still * publishes. */ - protected function validateContentLength(PostPlatform $postPlatform): void + protected function validateContentLength(Post $post): void { - if ($postPlatform->content_type?->isCaptionless()) { + if ($post->content_type?->isCaptionless()) { return; } - $raw = $postPlatform->post->content ?? ''; - $content = app(ContentSanitizer::class)->displayText($raw, $postPlatform->platform); + $raw = $post->content ?? ''; + $content = app(ContentSanitizer::class)->displayText($raw, $post->platform); - $threadViolation = ThreadReplies::violation($postPlatform->socialAccount ?? $postPlatform->platform, $postPlatform->meta); + $threadViolation = ThreadReplies::violation($post->socialAccount ?? $post->platform, $post->meta); if ($threadViolation !== null) { - throw new ContentLimitException($postPlatform->platform, $threadViolation[1]); + throw new ContentLimitException($post->platform, $threadViolation[1]); } - $reserved = $postPlatform->platform->reservedLength($postPlatform->meta); - $overflow = $postPlatform->socialAccount?->contentOverflow($content, $reserved) ?? $postPlatform->platform->contentOverflow($content, $reserved); + $reserved = $post->platform->reservedLength($post->meta); + $overflow = $post->socialAccount?->contentOverflow($content, $reserved) ?? $post->platform->contentOverflow($content, $reserved); if ($overflow === 0) { return; } - $maxLength = $postPlatform->socialAccount?->maxContentLength() ?? $postPlatform->platform->maxContentLength(); + $maxLength = $post->socialAccount?->maxContentLength() ?? $post->platform->maxContentLength(); $contentLength = mb_strlen($content) + $reserved; - throw ContentLimitException::exceeds($postPlatform->platform, $maxLength, $contentLength); + throw ContentLimitException::exceeds($post->platform, $maxLength, $contentLength); } private bool $interactiveHttp = false; diff --git a/app/Services/Social/Concerns/PublishesThreads.php b/app/Services/Social/Concerns/PublishesThreads.php index 6427f08eb6..f8812cb2ee 100644 --- a/app/Services/Social/Concerns/PublishesThreads.php +++ b/app/Services/Social/Concerns/PublishesThreads.php @@ -5,7 +5,7 @@ namespace App\Services\Social\Concerns; use App\Dto\MediaItem; -use App\Models\PostPlatform; +use App\Models\Post; use App\Services\Social\ContentSanitizer; use App\Support\Social\ThreadProgress; use App\Support\ThreadReplies; @@ -25,26 +25,26 @@ trait PublishesThreads * @param (callable(array): void)|null $afterThread * @return array{id: string, url: ?string, thread_reply_ids?: list} */ - protected function publishThread(PostPlatform $postPlatform, string $rootHash, callable $postRoot, callable $postReply, ?callable $afterThread = null): array + protected function publishThread(Post $post, string $rootHash, callable $postRoot, callable $postReply, ?callable $afterThread = null): array { $replies = array_map( fn (array $reply): array => [ - 'text' => app(ContentSanitizer::class)->sanitize($reply['text'], $postPlatform->platform), + 'text' => app(ContentSanitizer::class)->sanitize($reply['text'], $post->platform), 'media' => ThreadReplies::mediaItems($reply), ], - ThreadReplies::supports($postPlatform->platform) ? ThreadReplies::of($postPlatform->meta) : [], + ThreadReplies::supports($post->platform) ? ThreadReplies::of($post->meta) : [], ); $hashes = [ - ThreadProgress::rootHash($postPlatform->error_context) ?? $rootHash, + ThreadProgress::rootHash($post->error_context) ?? $rootHash, ...array_map(fn (array $reply): string => ThreadProgress::hash($reply['text'], $reply['media']->map(fn (MediaItem $item): string => $item->id)->all()), $replies), ]; - $posted = ThreadProgress::resumable($postPlatform->error_context, $hashes); + $posted = ThreadProgress::resumable($post->error_context, $hashes); if ($posted === []) { $posted[] = self::threadSegment($hashes[0], $postRoot()); if ($replies !== []) { - ThreadProgress::remember($postPlatform, $posted); + ThreadProgress::remember($post, $posted); } } @@ -57,7 +57,7 @@ protected function publishThread(PostPlatform $postPlatform, string $rootHash, c $posted[] = self::threadSegment($hashes[$position], $postReply($reply['text'], $reply['media'], $posted[$position - 1], $posted[0])); - ThreadProgress::remember($postPlatform, $posted); + ThreadProgress::remember($post, $posted); } if ($afterThread !== null) { diff --git a/app/Services/Social/Discord/DiscordPublisher.php b/app/Services/Social/Discord/DiscordPublisher.php index 44b6a01508..096c9406e8 100644 --- a/app/Services/Social/Discord/DiscordPublisher.php +++ b/app/Services/Social/Discord/DiscordPublisher.php @@ -9,7 +9,7 @@ use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\DiscordPublishException; use App\Exceptions\Social\ErrorCategory; -use App\Models\PostPlatform; +use App\Models\Post; use App\Services\Media\MediaOptimizer; use App\Services\Social\Concerns\HasSocialHttpClient; use App\Services\Social\ContentSanitizer; @@ -31,13 +31,13 @@ class DiscordPublisher private const MAX_EMBED_COLOR = 0xFFFFFF; - public function publish(PostPlatform $postPlatform): array + public function publish(Post $post): array { - $this->validateContentLength($postPlatform); + $this->validateContentLength($post); - $account = $postPlatform->socialAccount; + $account = $post->socialAccount; $guildId = (string) $account->platform_user_id; - $channelId = (string) data_get($postPlatform->meta, 'channel_id'); + $channelId = (string) data_get($post->meta, 'channel_id'); if ($channelId === '') { throw new DiscordPublishException( @@ -52,11 +52,11 @@ public function publish(PostPlatform $postPlatform): array // bot is in (including another workspace's). $this->guardChannelBelongsToGuild($guildId, $channelId); - $content = $postPlatform->post->content - ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) + $content = $post->content + ? app(ContentSanitizer::class)->sanitize($post->content, $post->platform) : ''; - $content = $this->appendMentions($content, $postPlatform); + $content = $this->appendMentions($content, $post); if (mb_strlen($content) > self::MAX_MESSAGE_LENGTH) { throw new DiscordPublishException( @@ -65,13 +65,13 @@ public function publish(PostPlatform $postPlatform): array ); } - $embeds = $this->buildEmbeds($postPlatform); - $media = $postPlatform->post->mediaItems->take($postPlatform->platform->maxImages()); + $embeds = $this->buildEmbeds($post); + $media = $post->mediaItems->take($post->platform->maxImages()); $payload = array_filter([ 'content' => $content !== '' ? $content : null, 'embeds' => $embeds !== [] ? $embeds : null, - 'allowed_mentions' => $this->allowedMentions($postPlatform), + 'allowed_mentions' => $this->allowedMentions($post), ], fn ($value) => $value !== null); $messageId = $media->isEmpty() @@ -187,9 +187,9 @@ private function downloadMedia(MediaItem $item): string * meta — not the shared content — so they only ping on Discord and don't leak * literal `<@id>` markup to other platforms. */ - private function appendMentions(string $content, PostPlatform $postPlatform): string + private function appendMentions(string $content, Post $post): string { - $tokens = $this->mentionTokens($postPlatform)->implode(' '); + $tokens = $this->mentionTokens($post)->implode(' '); if ($tokens === '') { return $content; @@ -205,13 +205,13 @@ private function appendMentions(string $content, PostPlatform $postPlatform): st * * @return array */ - private function allowedMentions(PostPlatform $postPlatform): array + private function allowedMentions(Post $post): array { $parse = []; $users = []; $roles = []; - foreach ($this->mentionTokens($postPlatform) as $token) { + foreach ($this->mentionTokens($post) as $token) { if ($token === '@everyone' || $token === '@here') { $parse[] = 'everyone'; } elseif (preg_match('/^<@&(\d+)>$/', $token, $matches)) { @@ -237,9 +237,9 @@ private function allowedMentions(PostPlatform $postPlatform): array /** * @return Collection */ - private function mentionTokens(PostPlatform $postPlatform): Collection + private function mentionTokens(Post $post): Collection { - return collect((array) data_get($postPlatform->meta, 'mentions', [])) + return collect((array) data_get($post->meta, 'mentions', [])) ->map(fn ($mention) => trim((string) data_get($mention, 'token'))) ->filter() ->values(); @@ -248,9 +248,9 @@ private function mentionTokens(PostPlatform $postPlatform): Collection /** * @return list> */ - private function buildEmbeds(PostPlatform $postPlatform): array + private function buildEmbeds(Post $post): array { - return collect((array) data_get($postPlatform->meta, 'embeds', [])) + return collect((array) data_get($post->meta, 'embeds', [])) ->map(fn ($embed) => array_filter([ 'title' => data_get($embed, 'title'), 'description' => data_get($embed, 'description'), diff --git a/app/Services/Social/FacebookPublisher.php b/app/Services/Social/FacebookPublisher.php index cc436ffbba..1a0113bcb2 100644 --- a/app/Services/Social/FacebookPublisher.php +++ b/app/Services/Social/FacebookPublisher.php @@ -11,7 +11,7 @@ use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\FacebookPublishException; use App\Exceptions\Social\SocialPublishException; -use App\Models\PostPlatform; +use App\Models\Post; use App\Services\Social\Concerns\FitsImageToCanvas; use App\Services\Social\Concerns\HasSocialHttpClient; use App\Services\Social\Meta\GraphError; @@ -45,21 +45,21 @@ public function __construct() /** * @return array{id: mixed, url: string} */ - public function publish(PostPlatform $postPlatform): array + public function publish(Post $post): array { - $this->validateContentLength($postPlatform); + $this->validateContentLength($post); - $account = $postPlatform->socialAccount; + $account = $post->socialAccount; $pageId = $account->platform_user_id; $accessToken = $account->access_token; - $content = $this->sanitizedContent($postPlatform); - $media = $postPlatform->post->mediaItems; - $contentType = $postPlatform->content_type; + $content = $this->sanitizedContent($post); + $media = $post->mediaItems; + $contentType = $post->content_type; return match ($contentType) { ContentType::FacebookReel => $this->publishReel($pageId, $accessToken, $content, $this->requireVideo($media->first(), 'Reels')), ContentType::FacebookStory => $this->publishStory($pageId, $accessToken, $this->requireMedia($media->first(), 'Stories')), - ContentType::FacebookPost => $this->publishPost($pageId, $accessToken, $content, $media, $postPlatform->attachesLinkPreview()), + ContentType::FacebookPost => $this->publishPost($pageId, $accessToken, $content, $media, $post->attachesLinkPreview()), default => throw new FacebookPublishException( userMessage: "Unsupported Facebook content type: {$contentType?->value}", category: ErrorCategory::MediaFormat, @@ -554,12 +554,12 @@ private function requireVideo(?MediaItem $media, string $format): MediaItem return $media; } - private function sanitizedContent(PostPlatform $postPlatform): ?string + private function sanitizedContent(Post $post): ?string { - $content = $postPlatform->post->content; + $content = $post->content; return filled($content) - ? app(ContentSanitizer::class)->sanitize($content, $postPlatform->platform) + ? app(ContentSanitizer::class)->sanitize($content, $post->platform) : null; } diff --git a/app/Services/Social/GoogleBusinessPublisher.php b/app/Services/Social/GoogleBusinessPublisher.php index b8983f88a6..1630249e01 100644 --- a/app/Services/Social/GoogleBusinessPublisher.php +++ b/app/Services/Social/GoogleBusinessPublisher.php @@ -13,7 +13,7 @@ use App\Enums\User\Locale; use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\GoogleBusinessPublishException; -use App\Models\PostPlatform; +use App\Models\Post; use App\Models\SocialAccount; use App\Services\Media\MediaOptimizer; use App\Services\Social\Concerns\HasSocialHttpClient; @@ -29,14 +29,14 @@ class GoogleBusinessPublisher { use HasSocialHttpClient; - public function publish(PostPlatform $postPlatform): array + public function publish(Post $post): array { $state = null; try { - $this->validateContentLength($postPlatform); + $this->validateContentLength($post); - $account = $postPlatform->socialAccount; + $account = $post->socialAccount; if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); @@ -51,7 +51,7 @@ public function publish(PostPlatform $postPlatform): array $created = $this->post( $account->access_token, $this->url('local_posts_api', "/{$locationId}/localPosts"), - $this->payload($postPlatform), + $this->payload($post), 'Google Business Profile post creation failed', ); $state = LocalPostState::fromApi(data_get($created, 'state')); @@ -63,7 +63,7 @@ public function publish(PostPlatform $postPlatform): array ]; } finally { if (! $state?->isPendingReview()) { - app(GoogleBusinessDerivativeCleaner::class)->cleanup($postPlatform->id); + app(GoogleBusinessDerivativeCleaner::class)->cleanup($post); } } } @@ -118,22 +118,22 @@ public function fetchLocalPost(SocialAccount $account, string $localPostName): a /** * @return array */ - private function payload(PostPlatform $postPlatform): array + private function payload(Post $post): array { - $topicType = TopicType::fromMeta(data_get($postPlatform->meta, 'topic_type')); - $locale = $postPlatform->post->user?->locale ?? Locale::DEFAULT; + $topicType = TopicType::fromMeta(data_get($post->meta, 'topic_type')); + $locale = $post->user?->locale ?? Locale::DEFAULT; return [ 'languageCode' => $locale->bcp47(), - 'summary' => $postPlatform->post->content - ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, Platform::GoogleBusiness) + 'summary' => $post->content + ? app(ContentSanitizer::class)->sanitize($post->content, Platform::GoogleBusiness) : '', 'topicType' => $topicType->value, ...array_filter([ - 'callToAction' => $this->callToAction($postPlatform, $topicType), - 'media' => $this->photo($postPlatform), - 'event' => $topicType->requiresEvent() ? $this->event($postPlatform, $topicType) : null, - 'offer' => $topicType === TopicType::Offer ? $this->offer($postPlatform) : null, + 'callToAction' => $this->callToAction($post, $topicType), + 'media' => $this->photo($post), + 'event' => $topicType->requiresEvent() ? $this->event($post, $topicType) : null, + 'offer' => $topicType === TopicType::Offer ? $this->offer($post) : null, ]), ]; } @@ -141,9 +141,9 @@ private function payload(PostPlatform $postPlatform): array /** * @return array{actionType: string, url?: mixed}|null */ - private function callToAction(PostPlatform $postPlatform, TopicType $topicType): ?array + private function callToAction(Post $post, TopicType $topicType): ?array { - $action = CtaAction::fromMeta(data_get($postPlatform->meta, 'call_to_action.action_type')); + $action = CtaAction::fromMeta(data_get($post->meta, 'call_to_action.action_type')); if (! $topicType->allowsCallToAction() || $action === CtaAction::None) { return null; @@ -152,7 +152,7 @@ private function callToAction(PostPlatform $postPlatform, TopicType $topicType): return [ 'actionType' => $action->value, ...($action->requiresUrl() ? [ - 'url' => data_get($postPlatform->meta, 'call_to_action.url'), + 'url' => data_get($post->meta, 'call_to_action.url'), ] : []), ]; } @@ -160,19 +160,19 @@ private function callToAction(PostPlatform $postPlatform, TopicType $topicType): /** * @return list|null */ - private function photo(PostPlatform $postPlatform): ?array + private function photo(Post $post): ?array { - $media = $postPlatform->post->mediaItems->first( + $media = $post->mediaItems->first( fn (MediaItem $item): bool => $item->isImage() && ! MediaType::isGif($item->mime_type), ); return $media ? [[ 'mediaFormat' => 'PHOTO', - 'sourceUrl' => $this->imageSourceUrl($media, $postPlatform->id), + 'sourceUrl' => $this->imageSourceUrl($media, $post), ]] : null; } - private function imageSourceUrl(MediaItem $media, string $postPlatformId): string + private function imageSourceUrl(MediaItem $media, Post $post): string { if (blank($media->path) || ! Storage::exists($media->path)) { return $media->url; @@ -188,7 +188,7 @@ private function imageSourceUrl(MediaItem $media, string $postPlatformId): strin try { file_put_contents($input, Storage::get($media->path)); $optimized = app(MediaOptimizer::class)->optimizeImage($input, Platform::GoogleBusiness); - $path = GoogleBusinessDerivativeCleaner::pathFor($postPlatformId); + $path = GoogleBusinessDerivativeCleaner::pathFor($post); Storage::put($path, file_get_contents($optimized)); return Storage::url($path); @@ -211,22 +211,22 @@ private function imageSourceUrl(MediaItem $media, string $postPlatformId): strin /** * @return array{title: string, schedule: array} */ - private function event(PostPlatform $postPlatform, TopicType $topicType): array + private function event(Post $post, TopicType $topicType): array { $datesRequired = __('posts.errors.google_business.event_dates_required'); return [ 'title' => $this->required( - data_get($postPlatform->meta, 'event.title'), + data_get($post->meta, 'event.title'), $topicType === TopicType::Offer ? __('posts.form.google_business.offer_title_required') : __('posts.form.google_business.event_title_required'), ), 'schedule' => array_filter([ - 'startDate' => $this->dateParts($this->required(data_get($postPlatform->meta, 'event.start_date'), $datesRequired)), - 'endDate' => $this->dateParts($this->required(data_get($postPlatform->meta, 'event.end_date'), $datesRequired)), - 'startTime' => $this->timeParts(data_get($postPlatform->meta, 'event.start_time')), - 'endTime' => $this->timeParts(data_get($postPlatform->meta, 'event.end_time')), + 'startDate' => $this->dateParts($this->required(data_get($post->meta, 'event.start_date'), $datesRequired)), + 'endDate' => $this->dateParts($this->required(data_get($post->meta, 'event.end_date'), $datesRequired)), + 'startTime' => $this->timeParts(data_get($post->meta, 'event.start_time')), + 'endTime' => $this->timeParts(data_get($post->meta, 'event.end_time')), ]), ]; } @@ -234,12 +234,12 @@ private function event(PostPlatform $postPlatform, TopicType $topicType): array /** * @return array */ - private function offer(PostPlatform $postPlatform): array + private function offer(Post $post): array { return array_filter([ - 'couponCode' => data_get($postPlatform->meta, 'offer.coupon_code'), - 'redeemOnlineUrl' => data_get($postPlatform->meta, 'offer.redeem_online_url'), - 'termsConditions' => data_get($postPlatform->meta, 'offer.terms_conditions'), + 'couponCode' => data_get($post->meta, 'offer.coupon_code'), + 'redeemOnlineUrl' => data_get($post->meta, 'offer.redeem_online_url'), + 'termsConditions' => data_get($post->meta, 'offer.terms_conditions'), ], filled(...)); } diff --git a/app/Services/Social/InstagramPublisher.php b/app/Services/Social/InstagramPublisher.php index 7f31c1b788..7bfb063d91 100644 --- a/app/Services/Social/InstagramPublisher.php +++ b/app/Services/Social/InstagramPublisher.php @@ -12,7 +12,7 @@ use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\InstagramPublishException; use App\Exceptions\Social\SocialPublishException; -use App\Models\PostPlatform; +use App\Models\Post; use App\Services\Social\Concerns\FitsImageToCanvas; use App\Services\Social\Concerns\HasSocialHttpClient; use App\Services\Social\Meta\GraphError; @@ -29,7 +29,7 @@ class InstagramPublisher private string $baseUrl; - private PostPlatform $postPlatform; + private Post $post; private const int STATUS_RETRY_DELAY_SECONDS = 60; @@ -40,12 +40,12 @@ class InstagramPublisher private const string WORKFLOW_FINAL_CONTAINER = 'final_container'; - public function publish(PostPlatform $postPlatform): array + public function publish(Post $post): array { - $this->postPlatform = $postPlatform; - $this->validateContentLength($postPlatform); + $this->post = $post; + $this->validateContentLength($post); - $account = $postPlatform->socialAccount; + $account = $post->socialAccount; $this->baseUrl = $account->platform->instagramGraphBaseUrl(); if ($account->needsProactiveTokenRefresh()) { @@ -55,15 +55,15 @@ public function publish(PostPlatform $postPlatform): array $instagramId = $account->platform_user_id; $accessToken = $account->access_token; - $content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null; + $content = $post->content ? app(ContentSanitizer::class)->sanitize($post->content, $post->platform) : null; - $pendingWorkflow = PublishCheckpoint::instagramWorkflow($postPlatform->error_context); + $pendingWorkflow = PublishCheckpoint::instagramWorkflow($post->error_context); if ($pendingWorkflow !== null) { return $this->resumeWorkflow($instagramId, $accessToken, $content, $pendingWorkflow); } - $media = $postPlatform->post->mediaItems; + $media = $post->mediaItems; if ($media->isEmpty()) { throw new InstagramPublishException( @@ -73,7 +73,7 @@ public function publish(PostPlatform $postPlatform): array } $firstMedia = $media->first(); - $contentType = $postPlatform->content_type; + $contentType = $post->content_type; return match ($contentType) { ContentType::InstagramReel => $this->publishReel($instagramId, $accessToken, $content, $firstMedia), @@ -249,7 +249,7 @@ private function userTagsParam(array $tags): array private function sharedOptions(): array { return array_filter([ - 'is_ai_generated' => data_get($this->postPlatform->meta, 'is_ai_generated') === true ? 'true' : null, + 'is_ai_generated' => data_get($this->post->meta, 'is_ai_generated') === true ? 'true' : null, ], fn (?string $value): bool => $value !== null); } @@ -261,12 +261,12 @@ private function sharedOptions(): array */ private function reelOptions(): array { - if ($this->postPlatform->content_type !== ContentType::InstagramReel) { + if ($this->post->content_type !== ContentType::InstagramReel) { return []; } return [ - 'share_to_feed' => data_get($this->postPlatform->meta, 'share_to_feed', true) ? 'true' : 'false', + 'share_to_feed' => data_get($this->post->meta, 'share_to_feed', true) ? 'true' : 'false', ]; } @@ -458,9 +458,9 @@ private function waitForMediaProcessing(string $containerId, string $accessToken */ private function rememberPublishedMedia(string $containerId, string $mediaId): void { - $this->postPlatform->update([ + $this->post->writePublication([ 'error_context' => [ - ...($this->postPlatform->error_context ?? []), + ...($this->post->error_context ?? []), PublishCheckpoint::INSTAGRAM_WORKFLOW => [ 'stage' => self::WORKFLOW_FINAL_CONTAINER, 'container_id' => $containerId, diff --git a/app/Services/Social/MastodonPublisher.php b/app/Services/Social/MastodonPublisher.php index 5259af4ec9..4c62cdf14c 100644 --- a/app/Services/Social/MastodonPublisher.php +++ b/app/Services/Social/MastodonPublisher.php @@ -10,7 +10,7 @@ use App\Exceptions\PlatformUnavailableException; use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\MastodonPublishException; -use App\Models\PostPlatform; +use App\Models\Post; use App\Models\SocialAccount; use App\Services\Media\MediaOptimizer; use App\Services\Social\Concerns\HasSocialHttpClient; @@ -29,29 +29,29 @@ class MastodonPublisher use HasSocialHttpClient; use PublishesThreads; - public function publish(PostPlatform $postPlatform): array + public function publish(Post $post): array { - $this->validateContentLength($postPlatform); + $this->validateContentLength($post); - $content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null; + $content = $post->content ? app(ContentSanitizer::class)->sanitize($post->content, $post->platform) : null; - $account = $postPlatform->socialAccount; + $account = $post->socialAccount; $instance = $account->meta['instance'] ?? config('trypost.platforms.mastodon.default_instance'); - $rootHash = ThreadProgress::hash((string) $content, $postPlatform->post->mediaItems->map(fn (MediaItem $item): string => $item->id)->all()); + $rootHash = ThreadProgress::hash((string) $content, $post->mediaItems->map(fn (MediaItem $item): string => $item->id)->all()); return $this->publishThread( - $postPlatform, + $post, $rootHash, - fn (): array => $this->publishRoot($postPlatform, $account, $instance, $content, "{$postPlatform->id}:{$rootHash}"), + fn (): array => $this->publishRoot($post, $account, $instance, $content, "{$post->id}:{$rootHash}"), fn (string $text, Collection $media, array $parent): array => $this->createStatus( $account, $instance, [ - ...$this->replyPayload($postPlatform, $text, (string) $parent['id']), + ...$this->replyPayload($post, $text, (string) $parent['id']), ...$this->mediaPayload($account, $instance, $media), ], - "{$postPlatform->id}:{$parent['id']}:".ThreadProgress::hash($text, $media->map(fn (MediaItem $item): string => $item->id)->all()), + "{$post->id}:{$parent['id']}:".ThreadProgress::hash($text, $media->map(fn (MediaItem $item): string => $item->id)->all()), ), ); } @@ -59,15 +59,15 @@ public function publish(PostPlatform $postPlatform): array /** * @return array{id: string, url: ?string} */ - private function publishRoot(PostPlatform $postPlatform, SocialAccount $account, string $instance, ?string $content, string $idempotencyKey): array + private function publishRoot(Post $post, SocialAccount $account, string $instance, ?string $content, string $idempotencyKey): array { $payload = [ 'status' => $content ?? '', 'visibility' => 'public', - ...$this->mediaPayload($account, $instance, $postPlatform->post->mediaItems), + ...$this->mediaPayload($account, $instance, $post->mediaItems), ]; - return $this->createStatus($account, $instance, [...$payload, ...$this->contentWarning($postPlatform)], $idempotencyKey); + return $this->createStatus($account, $instance, [...$payload, ...$this->contentWarning($post)], $idempotencyKey); } /** @@ -93,22 +93,22 @@ private function mediaPayload(SocialAccount $account, string $instance, Collecti * * @return array */ - private function replyPayload(PostPlatform $postPlatform, string $text, string $inReplyToId): array + private function replyPayload(Post $post, string $text, string $inReplyToId): array { return [ 'status' => $text, 'visibility' => 'public', 'in_reply_to_id' => $inReplyToId, - ...$this->contentWarning($postPlatform), + ...$this->contentWarning($post), ]; } /** * @return array{spoiler_text?: string} */ - private function contentWarning(PostPlatform $postPlatform): array + private function contentWarning(Post $post): array { - $spoilerText = Str::trim((string) data_get($postPlatform->meta, 'spoiler_text')); + $spoilerText = Str::trim((string) data_get($post->meta, 'spoiler_text')); return $spoilerText === '' ? [] : ['spoiler_text' => $spoilerText]; } diff --git a/app/Services/Social/PinterestPublisher.php b/app/Services/Social/PinterestPublisher.php index eef5b34a37..2105e2a070 100644 --- a/app/Services/Social/PinterestPublisher.php +++ b/app/Services/Social/PinterestPublisher.php @@ -11,7 +11,7 @@ use App\Exceptions\PlatformUnavailableException; use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\PinterestPublishException; -use App\Models\PostPlatform; +use App\Models\Post; use App\Models\SocialAccount; use App\Services\Media\MediaOptimizer; use App\Services\Social\Concerns\HasSocialHttpClient; @@ -33,26 +33,26 @@ public function __construct() $this->baseUrl = config('trypost.platforms.pinterest.api'); } - public function publish(PostPlatform $postPlatform): array + public function publish(Post $post): array { - $this->validateContentLength($postPlatform); + $this->validateContentLength($post); - $account = $postPlatform->socialAccount; + $account = $post->socialAccount; if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } - $content = $postPlatform->post->content - ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) + $content = $post->content + ? app(ContentSanitizer::class)->sanitize($post->content, $post->platform) : null; - return match ($postPlatform->content_type) { - ContentType::PinterestPin => $this->publishImagePin($postPlatform, $content), - ContentType::PinterestVideoPin => $this->publishVideoPin($postPlatform, $content), - ContentType::PinterestCarousel => $this->publishCarousel($postPlatform, $content), + return match ($post->content_type) { + ContentType::PinterestPin => $this->publishImagePin($post, $content), + ContentType::PinterestVideoPin => $this->publishVideoPin($post, $content), + ContentType::PinterestCarousel => $this->publishCarousel($post, $content), default => throw new PinterestPublishException( - userMessage: "Unsupported content type: {$postPlatform->content_type->value}", + userMessage: "Unsupported content type: {$post->content_type->value}", category: ErrorCategory::ContentPolicy, ), }; @@ -64,27 +64,27 @@ public function publish(PostPlatform $postPlatform): array * @param array $payload * @return array */ - private function applyPinTextFields(array $payload, PostPlatform $postPlatform, ?string $content): array + private function applyPinTextFields(array $payload, Post $post, ?string $content): array { if (filled($content)) { $payload['description'] = mb_substr($content, 0, 800); } - if (filled(data_get($postPlatform->meta, 'title'))) { - $payload['title'] = mb_substr((string) data_get($postPlatform->meta, 'title'), 0, 100); + if (filled(data_get($post->meta, 'title'))) { + $payload['title'] = mb_substr((string) data_get($post->meta, 'title'), 0, 100); } - if (filled(data_get($postPlatform->meta, 'link'))) { - $payload['link'] = data_get($postPlatform->meta, 'link'); + if (filled(data_get($post->meta, 'link'))) { + $payload['link'] = data_get($post->meta, 'link'); } return $payload; } - private function resolveBoardId(PostPlatform $postPlatform): string + private function resolveBoardId(Post $post): string { - $boardId = data_get($postPlatform->meta, 'board_id') - ?? data_get($postPlatform->socialAccount->meta, 'default_board_id'); + $boardId = data_get($post->meta, 'board_id') + ?? data_get($post->socialAccount->meta, 'default_board_id'); if (! filled($boardId)) { throw new PinterestPublishException( @@ -121,10 +121,10 @@ private function createPin(SocialAccount $account, array $payload, string $failu ]; } - private function publishImagePin(PostPlatform $postPlatform, ?string $content): array + private function publishImagePin(Post $post, ?string $content): array { - $account = $postPlatform->socialAccount; - $media = $postPlatform->post->mediaItems->first(); + $account = $post->socialAccount; + $media = $post->mediaItems->first(); if (! $media) { throw new PinterestPublishException( @@ -133,7 +133,7 @@ private function publishImagePin(PostPlatform $postPlatform, ?string $content): ); } - $boardId = $this->resolveBoardId($postPlatform); + $boardId = $this->resolveBoardId($post); // Download and optimize image $tempFile = tempnam(sys_get_temp_dir(), 'pin_image_'); @@ -168,9 +168,9 @@ private function publishImagePin(PostPlatform $postPlatform, ?string $content): 'content_type' => 'image/jpeg', 'data' => $imageBase64, ], - ], $postPlatform, $content); + ], $post, $content); - $alt = $postPlatform->post->mediaItems->first(fn ($m) => $m->isImage())?->altTextFor(Platform::Pinterest); + $alt = $post->mediaItems->first(fn ($m) => $m->isImage())?->altTextFor(Platform::Pinterest); if ($alt !== null) { $payload['alt_text'] = $alt; @@ -179,10 +179,10 @@ private function publishImagePin(PostPlatform $postPlatform, ?string $content): return $this->createPin($account, $payload, 'Pinterest pin creation failed'); } - private function publishVideoPin(PostPlatform $postPlatform, ?string $content): array + private function publishVideoPin(Post $post, ?string $content): array { - $account = $postPlatform->socialAccount; - $media = $postPlatform->post->mediaItems->first(); + $account = $post->socialAccount; + $media = $post->mediaItems->first(); if (! $media) { throw new PinterestPublishException( @@ -191,7 +191,7 @@ private function publishVideoPin(PostPlatform $postPlatform, ?string $content): ); } - $boardId = $this->resolveBoardId($postPlatform); + $boardId = $this->resolveBoardId($post); // Step 1: Register media upload $registerResponse = $this->socialHttp()->withToken($account->access_token) @@ -287,17 +287,17 @@ private function publishVideoPin(PostPlatform $postPlatform, ?string $content): 'source_type' => 'video_id', 'media_id' => $mediaId, ], - ], $postPlatform, $content); + ], $post, $content); $payload['media_source']['cover_image_key_frame_time'] = (int) round(($media->coverOffsetMs() ?? 0) / 1000); return $this->createPin($account, $payload, 'Pinterest video pin creation failed'); } - private function publishCarousel(PostPlatform $postPlatform, ?string $content): array + private function publishCarousel(Post $post, ?string $content): array { - $account = $postPlatform->socialAccount; - $medias = $postPlatform->post->mediaItems; + $account = $post->socialAccount; + $medias = $post->mediaItems; if ($medias->count() < 2 || $medias->count() > 5) { throw new PinterestPublishException( @@ -311,12 +311,12 @@ private function publishCarousel(PostPlatform $postPlatform, ?string $content): ])->toArray(); $payload = $this->applyPinTextFields([ - 'board_id' => $this->resolveBoardId($postPlatform), + 'board_id' => $this->resolveBoardId($post), 'media_source' => [ 'source_type' => 'multiple_image_urls', 'items' => $items, ], - ], $postPlatform, $content); + ], $post, $content); return $this->createPin($account, $payload, 'Pinterest carousel creation failed'); } diff --git a/app/Services/Social/Telegram/TelegramPublisher.php b/app/Services/Social/Telegram/TelegramPublisher.php index 7692f22cd5..61250490a4 100644 --- a/app/Services/Social/Telegram/TelegramPublisher.php +++ b/app/Services/Social/Telegram/TelegramPublisher.php @@ -6,7 +6,7 @@ use App\Dto\MediaItem; use App\Exceptions\Social\TelegramPublishException; -use App\Models\PostPlatform; +use App\Models\Post; use App\Models\SocialAccount; use App\Services\Social\Concerns\HasSocialHttpClient; use App\Services\Social\ContentSanitizer; @@ -26,18 +26,18 @@ class TelegramPublisher private const ALBUM_CHUNK = 10; - public function publish(PostPlatform $postPlatform): array + public function publish(Post $post): array { - $this->validateContentLength($postPlatform); + $this->validateContentLength($post); - $account = $postPlatform->socialAccount; + $account = $post->socialAccount; $chatId = (string) data_get($account->meta, 'chat_id'); - $content = $postPlatform->post->content - ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) + $content = $post->content + ? app(ContentSanitizer::class)->sanitize($post->content, $post->platform) : ''; - $media = $postPlatform->post->mediaItems->take(self::ALBUM_CHUNK); + $media = $post->mediaItems->take(self::ALBUM_CHUNK); $messageId = $media->isEmpty() ? $this->sendText($chatId, $content) diff --git a/app/Services/Social/ThreadsPublisher.php b/app/Services/Social/ThreadsPublisher.php index e2c4eddbf7..86dc8a438a 100644 --- a/app/Services/Social/ThreadsPublisher.php +++ b/app/Services/Social/ThreadsPublisher.php @@ -9,7 +9,7 @@ use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\ThreadsMediaContainerNotFoundException; use App\Exceptions\Social\ThreadsPublishException; -use App\Models\PostPlatform; +use App\Models\Post; use App\Services\Social\Concerns\HasSocialHttpClient; use App\Support\PostPlatformMetaRules; use Illuminate\Http\Client\ConnectionException; @@ -41,13 +41,13 @@ public function __construct() $this->baseUrl = config('trypost.platforms.threads.graph_api'); } - public function publish(PostPlatform $postPlatform): array + public function publish(Post $post): array { - $this->validateContentLength($postPlatform); + $this->validateContentLength($post); - $content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null; + $content = $post->content ? app(ContentSanitizer::class)->sanitize($post->content, $post->platform) : null; - $account = $postPlatform->socialAccount; + $account = $post->socialAccount; if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); @@ -56,18 +56,18 @@ public function publish(PostPlatform $postPlatform): array $userId = $account->platform_user_id; $accessToken = $account->access_token; - $media = $postPlatform->post->mediaItems; + $media = $post->mediaItems; - if ($postPlatform->content_type === ContentType::ThreadsGhostPost && $media->isNotEmpty()) { + if ($post->content_type === ContentType::ThreadsGhostPost && $media->isNotEmpty()) { throw new ThreadsPublishException( userMessage: __('posts.form.warnings.text_only'), category: ErrorCategory::MediaFormat, ); } - $this->containerOptions = $postPlatform->content_type === ContentType::ThreadsGhostPost + $this->containerOptions = $post->content_type === ContentType::ThreadsGhostPost ? ['is_ghost_post' => 'true'] - : $this->postOptions($postPlatform); + : $this->postOptions($post); // Text only post if ($media->isEmpty()) { @@ -430,9 +430,9 @@ private function waitForMediaProcessing(string $containerId, string $accessToken /** * @return array */ - private function postOptions(PostPlatform $postPlatform): array + private function postOptions(Post $post): array { - $topicTag = PostPlatformMetaRules::threadsTopicTag((string) data_get($postPlatform->meta, 'topic_tag')); + $topicTag = PostPlatformMetaRules::threadsTopicTag((string) data_get($post->meta, 'topic_tag')); return $topicTag === '' ? [] : ['topic_tag' => $topicTag]; } diff --git a/app/Services/Social/TikTokAnalytics.php b/app/Services/Social/TikTokAnalytics.php index 8d6b0a5ef4..9ead0bdaae 100644 --- a/app/Services/Social/TikTokAnalytics.php +++ b/app/Services/Social/TikTokAnalytics.php @@ -6,7 +6,7 @@ use App\Enums\SocialAccount\Platform; use App\Enums\TikTok\PrivacyLevel; -use App\Models\PostPlatform; +use App\Models\Post; use App\Models\SocialAccount; use App\Services\Social\Concerns\HasSocialHttpClient; use Illuminate\Http\Client\PendingRequest; @@ -65,17 +65,17 @@ public function getMetrics(SocialAccount $account): array * * @return array|array{unsupported: true, reason: string} */ - public function fetchPostMetrics(PostPlatform $postPlatform): array + public function fetchPostMetrics(Post $post): array { - $account = $postPlatform->socialAccount; + $account = $post->socialAccount; - if (! $account || ! $postPlatform->platform_post_id) { + if (! $account || ! $post->platform_post_id) { return ['unsupported' => true, 'reason' => 'missing_post_id']; } $this->prepareAccessToken($account); - $videoId = $this->videoIdFor($postPlatform); + $videoId = $this->videoIdFor($post); if ($videoId === null) { return ['unsupported' => true, 'reason' => 'missing_post_id']; @@ -117,9 +117,9 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array * the show-page link stops pointing at the profile. SELF_ONLY posts never * appear on the list, so they are not looked up. */ - public function findVideoIdByCaption(PostPlatform $postPlatform): ?string + public function findVideoIdByCaption(Post $post): ?string { - $account = $postPlatform->socialAccount; + $account = $post->socialAccount; if (! $account) { return null; @@ -127,30 +127,30 @@ public function findVideoIdByCaption(PostPlatform $postPlatform): ?string $this->prepareAccessToken($account); - return $this->matchVideoFromRecentList($postPlatform); + return $this->matchVideoFromRecentList($post); } - private function videoIdFor(PostPlatform $postPlatform): ?string + private function videoIdFor(Post $post): ?string { - $stored = (string) $postPlatform->platform_post_id; + $stored = (string) $post->platform_post_id; if (ctype_digit($stored)) { return $stored; } - $videoId = $this->publicVideoIdFromStatus($stored) ?? $this->matchVideoFromRecentList($postPlatform); + $videoId = $this->publicVideoIdFromStatus($stored) ?? $this->matchVideoFromRecentList($post); if ($videoId === null) { return null; } - $username = $postPlatform->socialAccount?->username; + $username = $post->socialAccount?->username; - $postPlatform->update([ + $post->writePublication([ 'platform_post_id' => $videoId, 'platform_url' => filled($username) ? "https://www.tiktok.com/@{$username}/video/{$videoId}" - : $postPlatform->platform_url, + : $post->platform_url, ]); return $videoId; @@ -180,21 +180,19 @@ private function publicVideoIdFromStatus(string $publishId): ?string * an older repost with the same caption must never be claimed. Allow one * day because our `published_at` can lag TikTok's `create_time` during review. */ - private function matchVideoFromRecentList(PostPlatform $postPlatform): ?string + private function matchVideoFromRecentList(Post $post): ?string { - if (PrivacyLevel::tryFrom((string) data_get($postPlatform->meta, 'privacy_level')) === PrivacyLevel::SelfOnly) { + if (PrivacyLevel::tryFrom((string) data_get($post->meta, 'privacy_level')) === PrivacyLevel::SelfOnly) { return null; } - $postPlatform->loadMissing('post'); - - $caption = $this->normalizeCaption((string) $postPlatform->post?->content); + $caption = $this->normalizeCaption((string) $post->content); if ($caption === '') { return null; } - $notBefore = ($postPlatform->published_at ?? now())->copy()->utc()->subDay()->getTimestamp(); + $notBefore = ($post->published_at ?? now())->copy()->utc()->subDay()->getTimestamp(); $cursor = null; for ($page = 0; $page < self::VIDEO_LIST_MAX_PAGES; $page++) { diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index ced328f5e2..94162964d1 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -11,7 +11,7 @@ use App\Exceptions\PlatformUnavailableException; use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\TikTokPublishException; -use App\Models\PostPlatform; +use App\Models\Post; use App\Models\SocialAccount; use App\Services\Media\MediaOptimizer; use App\Services\Social\Concerns\HasSocialHttpClient; @@ -43,13 +43,13 @@ public function __construct() $this->baseUrl = config('trypost.platforms.tiktok.api'); } - public function publish(PostPlatform $postPlatform): array + public function publish(Post $post): array { - $this->validateContentLength($postPlatform); + $this->validateContentLength($post); - $content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null; + $content = $post->content ? app(ContentSanitizer::class)->sanitize($post->content, $post->platform) : null; - $account = $postPlatform->socialAccount; + $account = $post->socialAccount; if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); @@ -57,17 +57,17 @@ public function publish(PostPlatform $postPlatform): array $this->accessToken = $account->access_token; - $pendingPublishId = PublishCheckpoint::tiktokPublishId($postPlatform->error_context); + $pendingPublishId = PublishCheckpoint::tiktokPublishId($post->error_context); if ($pendingPublishId !== null) { return $this->completePublishWithCleanup( - $postPlatform, + $post, $pendingPublishId, - PublishCheckpoint::tiktokDerivativePaths($postPlatform->error_context), + PublishCheckpoint::tiktokDerivativePaths($post->error_context), ); } - $media = $postPlatform->post->mediaItems; + $media = $post->mediaItems; if ($media->isEmpty()) { throw new TikTokPublishException( @@ -81,11 +81,11 @@ public function publish(PostPlatform $postPlatform): array $isImage = $firstMedia->isImage(); if ($isVideo) { - return $this->publishVideo($postPlatform, $firstMedia, $content); + return $this->publishVideo($post, $firstMedia, $content); } if ($isImage) { - return $this->publishPhotos($postPlatform, $media, $content); + return $this->publishPhotos($post, $media, $content); } throw new TikTokPublishException( @@ -105,9 +105,9 @@ private function getHttpClient(): PendingRequest * FormRequest enforces the same rules upstream; this is the safety net for * queue/job paths that bypass the request layer. */ - private function resolveRequiredPrivacyLevel(PostPlatform $postPlatform): PrivacyLevel + private function resolveRequiredPrivacyLevel(Post $post): PrivacyLevel { - $violation = PostPlatformMetaRules::requiredMetaViolation(Platform::TikTok, $postPlatform->meta); + $violation = PostPlatformMetaRules::requiredMetaViolation(Platform::TikTok, $post->meta); if ($violation !== null) { throw new TikTokPublishException( @@ -116,7 +116,7 @@ private function resolveRequiredPrivacyLevel(PostPlatform $postPlatform): Privac ); } - return PrivacyLevel::from((string) data_get($postPlatform->meta, 'privacy_level')); + return PrivacyLevel::from((string) data_get($post->meta, 'privacy_level')); } /** @@ -126,13 +126,13 @@ private function resolveRequiredPrivacyLevel(PostPlatform $postPlatform): Privac * * @return array */ - private function buildVideoPostInfo(PostPlatform $postPlatform, ?string $content, ?int $coverOffsetMs = null): array + private function buildVideoPostInfo(Post $post, ?string $content, ?int $coverOffsetMs = null): array { - $meta = $postPlatform->meta ?? []; + $meta = $post->meta ?? []; $postInfo = [ 'title' => $content ?? '', - 'privacy_level' => $this->resolveRequiredPrivacyLevel($postPlatform)->value, + 'privacy_level' => $this->resolveRequiredPrivacyLevel($post)->value, 'disable_duet' => ! data_get($meta, 'allow_duet', false), 'disable_comment' => ! data_get($meta, 'allow_comments', false), 'disable_stitch' => ! data_get($meta, 'allow_stitch', false), @@ -165,13 +165,13 @@ private function buildVideoPostInfo(PostPlatform $postPlatform, ?string $content * * @return array */ - private function buildPhotoPostInfo(PostPlatform $postPlatform, ?string $content): array + private function buildPhotoPostInfo(Post $post, ?string $content): array { - $meta = $postPlatform->meta ?? []; + $meta = $post->meta ?? []; $postInfo = [ 'description' => $content ?? '', - 'privacy_level' => $this->resolveRequiredPrivacyLevel($postPlatform)->value, + 'privacy_level' => $this->resolveRequiredPrivacyLevel($post)->value, 'disable_comment' => ! data_get($meta, 'allow_comments', false), ]; @@ -186,11 +186,11 @@ private function buildPhotoPostInfo(PostPlatform $postPlatform, ?string $content return $postInfo; } - private function publishVideo(PostPlatform $postPlatform, $media, ?string $content): array + private function publishVideo(Post $post, $media, ?string $content): array { $response = $this->getHttpClient() ->post("{$this->baseUrl}/post/publish/video/init/", [ - 'post_info' => $this->buildVideoPostInfo($postPlatform, $content, $media->coverOffsetMs()), + 'post_info' => $this->buildVideoPostInfo($post, $content, $media->coverOffsetMs()), 'source_info' => [ 'source' => 'PULL_FROM_URL', 'video_url' => $media->url, @@ -209,12 +209,12 @@ private function publishVideo(PostPlatform $postPlatform, $media, ?string $conte $publishId = $this->requirePublishId(data_get($data, 'data.publish_id')); - $this->rememberPublishId($postPlatform, $publishId); + $this->rememberPublishId($post, $publishId); - return $this->completePublish($postPlatform, $publishId); + return $this->completePublish($post, $publishId); } - private function publishPhotos(PostPlatform $postPlatform, $mediaCollection, ?string $content): array + private function publishPhotos(Post $post, $mediaCollection, ?string $content): array { $images = $mediaCollection->filter(fn ($m) => $m->isImage())->values(); @@ -239,10 +239,10 @@ private function publishPhotos(PostPlatform $postPlatform, $mediaCollection, ?st } } - $postInfo = $this->buildPhotoPostInfo($postPlatform, $content); + $postInfo = $this->buildPhotoPostInfo($post, $content); // Auto add music is only for photos. - $meta = $postPlatform->meta ?? []; + $meta = $post->meta ?? []; if (data_get($meta, 'auto_add_music', false)) { $postInfo['auto_add_music'] = true; } @@ -269,14 +269,14 @@ private function publishPhotos(PostPlatform $postPlatform, $mediaCollection, ?st $publishId = $this->requirePublishId(data_get($response->json(), 'data.publish_id')); - $this->rememberPublishId($postPlatform, $publishId, $derivatives); + $this->rememberPublishId($post, $publishId, $derivatives); } catch (Throwable $e) { app(TikTokPhotoDerivativeCleaner::class)->cleanupPaths($derivatives); throw $e; } - return $this->completePublishWithCleanup($postPlatform, $publishId, $derivatives); + return $this->completePublishWithCleanup($post, $publishId, $derivatives); } /** @@ -414,10 +414,10 @@ private function requirePublishId(mixed $publishId): string * * @param list $derivatives */ - private function rememberPublishId(PostPlatform $postPlatform, string $publishId, array $derivatives = []): void + private function rememberPublishId(Post $post, string $publishId, array $derivatives = []): void { $context = [ - ...($postPlatform->error_context ?? []), + ...($post->error_context ?? []), PublishCheckpoint::TIKTOK_PUBLISH_ID => $publishId, ]; @@ -425,7 +425,7 @@ private function rememberPublishId(PostPlatform $postPlatform, string $publishId $context[PublishCheckpoint::TIKTOK_DERIVATIVE_PATHS] = $derivatives; } - $postPlatform->update([ + $post->writePublication([ 'error_context' => $context, ]); } @@ -456,12 +456,12 @@ private function pendingPublishException(string $publishId, ?int $httpStatus = n * @param array $derivatives * @return array */ - private function completePublishWithCleanup(PostPlatform $postPlatform, string $publishId, array $derivatives): array + private function completePublishWithCleanup(Post $post, string $publishId, array $derivatives): array { $retainDerivatives = true; try { - $result = $this->completePublish($postPlatform, $publishId); + $result = $this->completePublish($post, $publishId); $retainDerivatives = false; return $result; @@ -480,18 +480,18 @@ private function completePublishWithCleanup(PostPlatform $postPlatform, string $ } } - private function completePublish(PostPlatform $postPlatform, string $publishId): array + private function completePublish(Post $post, string $publishId): array { $statusData = $this->waitForPublishStatus($publishId); $postId = (string) data_get($statusData, 'publicaly_available_post_id.0'); if (blank($postId)) { - $postId = app(TikTokAnalytics::class)->findVideoIdByCaption($postPlatform); + $postId = app(TikTokAnalytics::class)->findVideoIdByCaption($post); } return [ 'id' => $postId ?? $publishId, - 'url' => $this->buildTikTokUrl($postPlatform->socialAccount, $postId), + 'url' => $this->buildTikTokUrl($post->socialAccount, $postId), ]; } diff --git a/app/Services/Social/XPublisher.php b/app/Services/Social/XPublisher.php index ade2d3d8f3..d6160f3e90 100644 --- a/app/Services/Social/XPublisher.php +++ b/app/Services/Social/XPublisher.php @@ -11,7 +11,7 @@ use App\Exceptions\PlatformUnavailableException; use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\XPublishException; -use App\Models\PostPlatform; +use App\Models\Post; use App\Services\Media\MediaOptimizer; use App\Services\Social\Concerns\HasSocialHttpClient; use App\Services\Social\Concerns\PublishesThreads; @@ -57,27 +57,27 @@ public function __construct() $this->baseUrl = config('trypost.platforms.x.api'); } - public function publish(PostPlatform $postPlatform): array + public function publish(Post $post): array { - $this->validateContentLength($postPlatform); + $this->validateContentLength($post); - $content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null; + $content = $post->content ? app(ContentSanitizer::class)->sanitize($post->content, $post->platform) : null; - $account = $postPlatform->socialAccount; + $account = $post->socialAccount; if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } $this->accessToken = $account->access_token; - $this->uploadedMedia = PublishCheckpoint::xMedia($postPlatform->error_context); + $this->uploadedMedia = PublishCheckpoint::xMedia($post->error_context); - $rootHash = ThreadProgress::hash((string) $content, $postPlatform->post->mediaItems->map(fn (MediaItem $item): string => $item->id)->all()); + $rootHash = ThreadProgress::hash((string) $content, $post->mediaItems->map(fn (MediaItem $item): string => $item->id)->all()); return $this->publishThread( - $postPlatform, + $post, $rootHash, - fn (): array => $this->publishRoot($postPlatform, $content), + fn (): array => $this->publishRoot($post, $content), fn (string $text, Collection $media, array $parent): array => $this->createTweet([ ...($text !== '' ? ['text' => $text] : []), ...$this->mediaPayload($media), @@ -89,7 +89,7 @@ public function publish(PostPlatform $postPlatform): array /** * @return array{id: string, url: ?string} */ - private function publishRoot(PostPlatform $postPlatform, ?string $content): array + private function publishRoot(Post $post, ?string $content): array { $data = []; @@ -97,9 +97,9 @@ private function publishRoot(PostPlatform $postPlatform, ?string $content): arra $data['text'] = $content; } - $data = [...$data, ...$this->mediaPayload($postPlatform->post->mediaItems)]; + $data = [...$data, ...$this->mediaPayload($post->mediaItems)]; - if (data_get($postPlatform->meta, 'is_ai_generated') === true) { + if (data_get($post->meta, 'is_ai_generated') === true) { $data['made_with_ai'] = true; } @@ -110,7 +110,7 @@ private function publishRoot(PostPlatform $postPlatform, ?string $content): arra ); } - return $this->createTweet($data, $postPlatform->socialAccount->username); + return $this->createTweet($data, $post->socialAccount->username); } /** diff --git a/app/Services/Social/YouTubePublisher.php b/app/Services/Social/YouTubePublisher.php index 6d643dfb3b..7467c5fc78 100644 --- a/app/Services/Social/YouTubePublisher.php +++ b/app/Services/Social/YouTubePublisher.php @@ -10,7 +10,7 @@ use App\Exceptions\PlatformUnavailableException; use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\YouTubePublishException; -use App\Models\PostPlatform; +use App\Models\Post; use App\Models\SocialAccount; use App\Services\Social\Concerns\HasSocialHttpClient; use App\Support\YouTubeDescription; @@ -31,11 +31,11 @@ class YouTubePublisher private const CHUNK_SIZE = 10 * 1024 * 1024; // 10MB chunks - public function publish(PostPlatform $postPlatform): array + public function publish(Post $post): array { - $this->validateContentLength($postPlatform); + $this->validateContentLength($post); - $metadataViolation = YouTubeMetadata::violation($postPlatform->meta); + $metadataViolation = YouTubeMetadata::violation($post->meta); if ($metadataViolation !== null) { throw new YouTubePublishException( @@ -44,18 +44,18 @@ public function publish(PostPlatform $postPlatform): array ); } - $content = $postPlatform->post->content - ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) + $content = $post->content + ? app(ContentSanitizer::class)->sanitize($post->content, $post->platform) : null; - $description = $this->resolveDescription($postPlatform, $content); + $description = $this->resolveDescription($post, $content); - $account = $postPlatform->socialAccount; + $account = $post->socialAccount; if ($account->needsProactiveTokenRefresh()) { app(ConnectionVerifier::class)->refreshToken($account); } - $media = $postPlatform->post->mediaItems; + $media = $post->mediaItems; if ($media->isEmpty()) { throw new YouTubePublishException( @@ -73,7 +73,7 @@ public function publish(PostPlatform $postPlatform): array ); } - return $this->publishShort($firstMedia, $account, $content, $description, $postPlatform->meta ?? []); + return $this->publishShort($firstMedia, $account, $content, $description, $post->meta ?? []); } private function createGoogleClient(SocialAccount $account): GoogleClient @@ -241,10 +241,10 @@ private function uploadUnavailable(Exception $e): PlatformUnavailableException ); } - private function resolveDescription(PostPlatform $postPlatform, ?string $content): string + private function resolveDescription(Post $post, ?string $content): string { - $description = YouTubeDescription::resolve($postPlatform->meta, $content); - $violation = YouTubeDescription::violation(data_get($postPlatform->meta, 'description')) + $description = YouTubeDescription::resolve($post->meta, $content); + $violation = YouTubeDescription::violation(data_get($post->meta, 'description')) ?? YouTubeDescription::violation($description); if ($violation !== null) { diff --git a/app/Services/WebhookService.php b/app/Services/WebhookService.php index 2d8255d9f4..13ee2124ed 100644 --- a/app/Services/WebhookService.php +++ b/app/Services/WebhookService.php @@ -9,7 +9,6 @@ use App\Enums\Webhook\EventType as WebhookEvent; use App\Jobs\DispatchWebhook; use App\Models\Post; -use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Models\User; use App\Models\Webhook; @@ -106,7 +105,7 @@ public function dispatch(Workspace $workspace, WebhookEvent $event, array $paylo */ public function postPayload(Post $post): array { - $post->load(['user', 'workspace', 'labels', 'postPlatforms.socialAccount']); + $post->load(['user', 'workspace', 'labels', 'socialAccount']); return [ 'id' => $post->id, @@ -130,10 +129,18 @@ public function postPayload(Post $post): array ->values() ->all(), 'media' => $this->mediaPayload($post), - 'platforms' => $post->postPlatforms - ->map(fn (PostPlatform $platform): array => $this->platformPayload($platform)) - ->values() - ->all(), + 'publish_status' => $post->publish_status->value, + 'social_account_id' => $post->social_account_id, + 'platform' => $post->platform?->value, + 'content_type' => $post->content_type?->value, + 'platform_post_id' => $post->platform_post_id, + 'platform_url' => $post->platform_url, + 'error_message' => $post->error_message, + 'display_name' => $post->display_name, + 'display_username' => $post->display_username, + 'display_avatar' => $post->display_avatar, + 'meta' => $post->meta ?? [], + 'social_account' => $this->socialAccountPayload($post->socialAccount), ]; } @@ -188,31 +195,6 @@ private function mediaPayload(Post $post): array ->all(); } - /** - * @return array - */ - private function platformPayload(PostPlatform $platform): array - { - return [ - 'id' => $platform->id, - 'social_account_id' => $platform->social_account_id, - 'platform' => $platform->platform?->value, - 'content_type' => $platform->content_type?->value, - 'enabled' => $platform->enabled, - 'status' => $platform->status?->value, - 'platform_post_id' => $platform->platform_post_id, - 'platform_url' => $platform->platform_url, - 'published_at' => $platform->published_at?->toIso8601String(), - 'error_message' => $platform->error_message, - 'error_context' => $platform->error_context, - 'display_name' => $platform->display_name, - 'display_username' => $platform->display_username, - 'display_avatar' => $platform->display_avatar, - 'meta' => $platform->meta ?? [], - 'social_account' => $this->socialAccountPayload($platform->socialAccount), - ]; - } - /** * @return array{id: string, platform: string|null, display_name: string|null, username: string|null, status: string|null}|null */ diff --git a/app/Support/Mail/ApprovalEmailPosts.php b/app/Support/Mail/ApprovalEmailPosts.php index cad136fce6..9626f5834d 100644 --- a/app/Support/Mail/ApprovalEmailPosts.php +++ b/app/Support/Mail/ApprovalEmailPosts.php @@ -6,7 +6,6 @@ use App\Enums\Post\Status as PostStatus; use App\Models\Post; -use App\Models\PostPlatform; use App\Models\User; use Carbon\CarbonInterface; use Illuminate\Support\Collection; @@ -24,7 +23,7 @@ class ApprovalEmailPosts public static function load(array $postIds): Collection { return Post::query() - ->with(['workspace', 'postPlatforms' => fn ($platforms) => $platforms->enabled()->with('socialAccount')]) + ->with(['workspace', 'socialAccount']) ->whereIn('id', $postIds) ->orderBy('created_at') ->orderBy('id') @@ -38,12 +37,12 @@ public static function excerpt(?Post $post): string /** * @param Collection $posts - * @return list + * @return list */ public static function channels(Collection $posts): array { - return $posts->flatMap(fn (Post $post) => $post->postPlatforms) - ->unique(fn (PostPlatform $postPlatform): string => $postPlatform->social_account_id ?? $postPlatform->id) + return $posts->filter(fn (Post $post): bool => $post->hasDestination()) + ->unique(fn (Post $post): string => $post->social_account_id ?? $post->id) ->values() ->all(); } @@ -53,7 +52,7 @@ public static function channels(Collection $posts): array * publishing now. One shared moment collapses into a single line. * * @param Collection $posts - * @return array{at: ?string, perChannel: list, at: ?string}>} + * @return array{at: ?string, perChannel: list, at: ?string}>} */ public static function goesOut(Collection $posts, User $recipient): array { diff --git a/app/Support/PostCompositionValidator.php b/app/Support/PostCompositionValidator.php index 1d4ca45622..0006112bb7 100644 --- a/app/Support/PostCompositionValidator.php +++ b/app/Support/PostCompositionValidator.php @@ -120,25 +120,12 @@ public static function validate(Workspace $workspace, array $composition, array foreach (PostMediaRules::hostedRules() as $key => $rules) { $mediaRules[str_replace('media', 'destinations.*.media', $key)] = $rules; } - $metaRules = []; - foreach (PostPlatformMetaRules::rules() as $key => $rules) { - $metaRules[str_replace('platforms.', 'destinations.', $key)] = array_map( - fn (mixed $rule): mixed => is_string($rule) - ? str_replace('platforms.', 'destinations.', $rule) - : $rule, - $rules, - ); - } - $metaMessages = []; - foreach (PostPlatformMetaRules::messages() as $key => $message) { - $metaMessages[str_replace('platforms.', 'destinations.', $key)] = $message; - } - $metaAttributes = []; - foreach (PostPlatformMetaRules::attributes() as $key => $attribute) { - $metaAttributes[str_replace('platforms.', 'destinations.', $key)] = $attribute; - } - - $validator = Validator::make($composition, [...$mediaRules, ...$metaRules], $metaMessages, $metaAttributes); + $validator = Validator::make( + $composition, + [...$mediaRules, ...PostPlatformMetaRules::rules('destinations.*.meta')], + PostPlatformMetaRules::messages('destinations.*.meta'), + PostPlatformMetaRules::attributes('destinations.*.meta'), + ); $validator->after(function (LaravelValidator $validator) use ($composition, $accounts, $assets, $existingMedia, $workspace): void { $seen = []; diff --git a/app/Support/PostPlatformMetaRules.php b/app/Support/PostPlatformMetaRules.php index bc8cc60c7a..8c28ae6482 100644 --- a/app/Support/PostPlatformMetaRules.php +++ b/app/Support/PostPlatformMetaRules.php @@ -12,7 +12,6 @@ use App\Enums\YouTube\License; use App\Enums\YouTube\PrivacyStatus; use App\Models\Post; -use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Rules\ValidYouTubeDescription; use App\Services\Social\ContentSanitizer; @@ -37,22 +36,23 @@ class PostPlatformMetaRules public const SYSTEM_KEYS = ['reactions']; /** - * Validation rules for `platforms.*.meta` and all its per-platform sub-keys. + * Validation rules for the post's meta and all its per-platform sub-keys, + * under `$prefix` (`meta`, or `destinations.*.meta` for a list of channels). * Spread into a FormRequest/MCP tool rule set as the complete meta contract. * * @return array */ - public static function rules(): array + public static function rules(string $prefix = 'meta'): array { return [ - 'platforms.*.meta' => ['sometimes', 'nullable', 'array'], + "{$prefix}" => ['sometimes', 'nullable', 'array'], - 'platforms.*.meta.share_to_feed' => ['sometimes', 'boolean'], + "{$prefix}.share_to_feed" => ['sometimes', 'boolean'], - 'platforms.*.meta.spoiler_text' => ['sometimes', 'nullable', 'string', 'max:500'], + "{$prefix}.spoiler_text" => ['sometimes', 'nullable', 'string', 'max:500'], - 'platforms.*.meta.thread_replies' => ['sometimes', 'nullable', 'array', 'max:'.ThreadReplies::MAX_REPLIES], - 'platforms.*.meta.thread_replies.*' => ['nullable', function (string $attribute, mixed $value, Closure $fail): void { + "{$prefix}.thread_replies" => ['sometimes', 'nullable', 'array', 'max:'.ThreadReplies::MAX_REPLIES], + "{$prefix}.thread_replies.*" => ['nullable', function (string $attribute, mixed $value, Closure $fail): void { if (! is_string($value) && ! is_array($value)) { $fail(__('validation.array', ['attribute' => $attribute])); } @@ -61,73 +61,73 @@ public static function rules(): array $fail(__('validation.max.string', ['attribute' => $attribute, 'max' => Post::CONTENT_MAX_LENGTH])); } }], - 'platforms.*.meta.thread_replies.*.text' => ['sometimes', 'nullable', 'string', 'max:'.Post::CONTENT_MAX_LENGTH], - 'platforms.*.meta.thread_replies.*.media' => ['sometimes', 'nullable', 'array'], - 'platforms.*.meta.thread_replies.*.media.*' => ['array'], - 'platforms.*.meta.thread_replies.*.media.*.id' => ['sometimes', 'nullable', 'string'], - 'platforms.*.meta.thread_replies.*.media.*.upload_token' => ['sometimes', 'nullable', 'string'], - 'platforms.*.meta.thread_replies.*.media.*.url' => ['sometimes', 'nullable', 'string'], - - 'platforms.*.meta.document_title' => ['sometimes', 'nullable', 'string', 'max:300'], - - 'platforms.*.meta.privacy_level' => ['sometimes', 'nullable', 'string', Rule::enum(PrivacyLevel::class)], - 'platforms.*.meta.auto_add_music' => ['sometimes', 'boolean'], - 'platforms.*.meta.allow_comments' => ['sometimes', 'boolean'], - 'platforms.*.meta.allow_duet' => ['sometimes', 'boolean'], - 'platforms.*.meta.allow_stitch' => ['sometimes', 'boolean'], - 'platforms.*.meta.is_aigc' => ['sometimes', 'boolean'], - 'platforms.*.meta.disclose' => ['sometimes', 'boolean'], - 'platforms.*.meta.brand_content_toggle' => ['sometimes', 'boolean'], - 'platforms.*.meta.brand_organic_toggle' => ['sometimes', 'boolean'], - - 'platforms.*.meta.board_id' => ['sometimes', 'nullable', 'string'], - 'platforms.*.meta.title' => ['sometimes', 'nullable', 'string', 'max:100'], - 'platforms.*.meta.link' => ['sometimes', 'nullable', 'url:http,https', 'max:2048'], - - 'platforms.*.meta.description' => ['sometimes', 'nullable', 'string', new ValidYouTubeDescription], - 'platforms.*.meta.category_id' => ['sometimes', 'nullable', Rule::in(array_column(Category::cases(), 'value'))], - 'platforms.*.meta.privacy_status' => ['sometimes', 'nullable', 'string', Rule::enum(PrivacyStatus::class)], - 'platforms.*.meta.license' => ['sometimes', 'nullable', 'string', Rule::enum(License::class)], - 'platforms.*.meta.notify_subscribers' => ['sometimes', 'boolean'], - 'platforms.*.meta.embeddable' => ['sometimes', 'boolean'], - 'platforms.*.meta.made_for_kids' => ['sometimes', 'boolean'], - - 'platforms.*.meta.is_ai_generated' => ['sometimes', 'boolean'], - - 'platforms.*.meta.link_preview' => ['sometimes', 'boolean:strict'], - - 'platforms.*.meta.topic_tag' => ['sometimes', 'nullable', 'string', function (string $attribute, mixed $value, Closure $fail): void { + "{$prefix}.thread_replies.*.text" => ['sometimes', 'nullable', 'string', 'max:'.Post::CONTENT_MAX_LENGTH], + "{$prefix}.thread_replies.*.media" => ['sometimes', 'nullable', 'array'], + "{$prefix}.thread_replies.*.media.*" => ['array'], + "{$prefix}.thread_replies.*.media.*.id" => ['sometimes', 'nullable', 'string'], + "{$prefix}.thread_replies.*.media.*.upload_token" => ['sometimes', 'nullable', 'string'], + "{$prefix}.thread_replies.*.media.*.url" => ['sometimes', 'nullable', 'string'], + + "{$prefix}.document_title" => ['sometimes', 'nullable', 'string', 'max:300'], + + "{$prefix}.privacy_level" => ['sometimes', 'nullable', 'string', Rule::enum(PrivacyLevel::class)], + "{$prefix}.auto_add_music" => ['sometimes', 'boolean'], + "{$prefix}.allow_comments" => ['sometimes', 'boolean'], + "{$prefix}.allow_duet" => ['sometimes', 'boolean'], + "{$prefix}.allow_stitch" => ['sometimes', 'boolean'], + "{$prefix}.is_aigc" => ['sometimes', 'boolean'], + "{$prefix}.disclose" => ['sometimes', 'boolean'], + "{$prefix}.brand_content_toggle" => ['sometimes', 'boolean'], + "{$prefix}.brand_organic_toggle" => ['sometimes', 'boolean'], + + "{$prefix}.board_id" => ['sometimes', 'nullable', 'string'], + "{$prefix}.title" => ['sometimes', 'nullable', 'string', 'max:100'], + "{$prefix}.link" => ['sometimes', 'nullable', 'url:http,https', 'max:2048'], + + "{$prefix}.description" => ['sometimes', 'nullable', 'string', new ValidYouTubeDescription], + "{$prefix}.category_id" => ['sometimes', 'nullable', Rule::in(array_column(Category::cases(), 'value'))], + "{$prefix}.privacy_status" => ['sometimes', 'nullable', 'string', Rule::enum(PrivacyStatus::class)], + "{$prefix}.license" => ['sometimes', 'nullable', 'string', Rule::enum(License::class)], + "{$prefix}.notify_subscribers" => ['sometimes', 'boolean'], + "{$prefix}.embeddable" => ['sometimes', 'boolean'], + "{$prefix}.made_for_kids" => ['sometimes', 'boolean'], + + "{$prefix}.is_ai_generated" => ['sometimes', 'boolean'], + + "{$prefix}.link_preview" => ['sometimes', 'boolean:strict'], + + "{$prefix}.topic_tag" => ['sometimes', 'nullable', 'string', function (string $attribute, mixed $value, Closure $fail): void { if (! self::isThreadsTopicTag((string) $value)) { $fail(__('posts.form.threads.topic_invalid')); } }], - 'platforms.*.meta.channel_id' => ['sometimes', 'nullable', 'string'], - 'platforms.*.meta.channel_name' => ['sometimes', 'nullable', 'string'], - 'platforms.*.meta.mentions' => ['sometimes', 'nullable', 'array'], - 'platforms.*.meta.mentions.*.token' => ['required', 'string'], - 'platforms.*.meta.mentions.*.label' => ['sometimes', 'nullable', 'string'], - 'platforms.*.meta.embeds' => ['sometimes', 'nullable', 'array', 'max:10'], - 'platforms.*.meta.embeds.*.title' => ['sometimes', 'nullable', 'string', 'max:256'], - 'platforms.*.meta.embeds.*.description' => ['sometimes', 'nullable', 'string', 'max:4096'], - 'platforms.*.meta.embeds.*.url' => ['sometimes', 'nullable', 'url'], - 'platforms.*.meta.embeds.*.image' => ['sometimes', 'nullable', 'url'], - 'platforms.*.meta.embeds.*.color' => ['sometimes', 'nullable', 'string', 'regex:/^#?[0-9A-Fa-f]{6}$/'], - - 'platforms.*.meta.topic_type' => ['sometimes', 'nullable', 'string', Rule::enum(TopicType::class)], - 'platforms.*.meta.call_to_action' => ['sometimes', 'nullable', 'array'], - 'platforms.*.meta.call_to_action.action_type' => ['sometimes', 'nullable', 'string', Rule::enum(CtaAction::class)], - 'platforms.*.meta.call_to_action.url' => ['sometimes', 'nullable', 'url:http,https', 'max:2048'], - 'platforms.*.meta.event' => ['sometimes', 'nullable', 'array'], - 'platforms.*.meta.event.title' => ['sometimes', 'nullable', 'string', 'max:'.TopicType::TITLE_MAX_LENGTH], - 'platforms.*.meta.event.start_date' => ['sometimes', 'nullable', 'date'], - 'platforms.*.meta.event.end_date' => ['sometimes', 'nullable', 'date', 'after_or_equal:platforms.*.meta.event.start_date'], - 'platforms.*.meta.event.start_time' => ['sometimes', 'nullable', 'date_format:H:i'], - 'platforms.*.meta.event.end_time' => ['sometimes', 'nullable', 'date_format:H:i'], - 'platforms.*.meta.offer' => ['sometimes', 'nullable', 'array'], - 'platforms.*.meta.offer.coupon_code' => ['sometimes', 'nullable', 'string'], - 'platforms.*.meta.offer.redeem_online_url' => ['sometimes', 'nullable', 'url:http,https', 'max:2048'], - 'platforms.*.meta.offer.terms_conditions' => ['sometimes', 'nullable', 'string', 'max:5000'], + "{$prefix}.channel_id" => ['sometimes', 'nullable', 'string'], + "{$prefix}.channel_name" => ['sometimes', 'nullable', 'string'], + "{$prefix}.mentions" => ['sometimes', 'nullable', 'array'], + "{$prefix}.mentions.*.token" => ['required', 'string'], + "{$prefix}.mentions.*.label" => ['sometimes', 'nullable', 'string'], + "{$prefix}.embeds" => ['sometimes', 'nullable', 'array', 'max:10'], + "{$prefix}.embeds.*.title" => ['sometimes', 'nullable', 'string', 'max:256'], + "{$prefix}.embeds.*.description" => ['sometimes', 'nullable', 'string', 'max:4096'], + "{$prefix}.embeds.*.url" => ['sometimes', 'nullable', 'url'], + "{$prefix}.embeds.*.image" => ['sometimes', 'nullable', 'url'], + "{$prefix}.embeds.*.color" => ['sometimes', 'nullable', 'string', 'regex:/^#?[0-9A-Fa-f]{6}$/'], + + "{$prefix}.topic_type" => ['sometimes', 'nullable', 'string', Rule::enum(TopicType::class)], + "{$prefix}.call_to_action" => ['sometimes', 'nullable', 'array'], + "{$prefix}.call_to_action.action_type" => ['sometimes', 'nullable', 'string', Rule::enum(CtaAction::class)], + "{$prefix}.call_to_action.url" => ['sometimes', 'nullable', 'url:http,https', 'max:2048'], + "{$prefix}.event" => ['sometimes', 'nullable', 'array'], + "{$prefix}.event.title" => ['sometimes', 'nullable', 'string', 'max:'.TopicType::TITLE_MAX_LENGTH], + "{$prefix}.event.start_date" => ['sometimes', 'nullable', 'date'], + "{$prefix}.event.end_date" => ['sometimes', 'nullable', 'date', "after_or_equal:{$prefix}.event.start_date"], + "{$prefix}.event.start_time" => ['sometimes', 'nullable', 'date_format:H:i'], + "{$prefix}.event.end_time" => ['sometimes', 'nullable', 'date_format:H:i'], + "{$prefix}.offer" => ['sometimes', 'nullable', 'array'], + "{$prefix}.offer.coupon_code" => ['sometimes', 'nullable', 'string'], + "{$prefix}.offer.redeem_online_url" => ['sometimes', 'nullable', 'url:http,https', 'max:2048'], + "{$prefix}.offer.terms_conditions" => ['sometimes', 'nullable', 'string', 'max:5000'], ]; } @@ -140,8 +140,8 @@ public static function rules(): array public static function onlyKnown(array $meta): array { $known = collect(array_keys(self::rules())) - ->filter(fn (string $key): bool => str_starts_with($key, 'platforms.*.meta.')) - ->map(fn (string $key): string => explode('.', substr($key, strlen('platforms.*.meta.')))[0]) + ->filter(fn (string $key): bool => str_starts_with($key, 'meta.')) + ->map(fn (string $key): string => explode('.', substr($key, strlen('meta.')))[0]) ->unique() ->all(); @@ -164,67 +164,35 @@ public static function forStorage(array $stored, array $meta): array ]; } - /** - * The meta of each submitted platform entry, keyed by the given field. - * - * @param array $platforms - * @return array> - */ - public static function metaByKey(array $platforms, string $keyField): array - { - return collect($platforms) - ->filter(fn (mixed $platform): bool => is_string(data_get($platform, $keyField))) - ->mapWithKeys(fn (mixed $platform): array => [data_get($platform, $keyField) => (array) data_get($platform, 'meta', [])]) - ->all(); - } - - /** - * The content type of each submitted platform entry, keyed by the given - * field, falling back to the stored one when the entry omits it. - * - * @param array $platforms - * @param array $stored - * @return array - */ - public static function contentTypesByKey(array $platforms, string $keyField, array $stored = []): array - { - return collect($platforms) - ->filter(fn (mixed $platform): bool => is_string(data_get($platform, $keyField))) - ->mapWithKeys(fn (mixed $platform): array => [ - data_get($platform, $keyField) => data_get($platform, 'content_type', data_get($stored, data_get($platform, $keyField))), - ]) - ->all(); - } - /** * Custom validation messages for meta fields shown in the UI. * * @return array */ - public static function messages(): array + public static function messages(string $prefix = 'meta'): array { return [ - 'platforms.*.meta.link.url' => __('posts.form.pinterest.link_invalid'), - 'platforms.*.meta.link.max' => __('posts.form.pinterest.link_max'), - 'platforms.*.meta.title.max' => __('posts.form.pinterest.title_max'), - 'platforms.*.meta.event.end_date.after_or_equal' => __('posts.form.google_business.event_end_date_before_start'), - 'platforms.*.meta.event.title.max' => __('posts.form.google_business.title_max'), + "{$prefix}.link.url" => __('posts.form.pinterest.link_invalid'), + "{$prefix}.link.max" => __('posts.form.pinterest.link_max'), + "{$prefix}.title.max" => __('posts.form.pinterest.title_max'), + "{$prefix}.event.end_date.after_or_equal" => __('posts.form.google_business.event_end_date_before_start'), + "{$prefix}.event.title.max" => __('posts.form.google_business.title_max'), ]; } /** - * Friendly attribute names so default messages never expose `platforms.0.meta.*`. + * Friendly attribute names so default messages never expose raw meta keys. * * @return array */ - public static function attributes(): array + public static function attributes(string $prefix = 'meta'): array { return [ - 'platforms.*.meta.title' => __('posts.form.pinterest.title'), - 'platforms.*.meta.description' => __('posts.form.youtube.description'), - 'platforms.*.meta.link' => __('posts.form.pinterest.link'), - 'platforms.*.meta.event.title' => __('posts.form.google_business.event_title'), - 'platforms.*.meta.call_to_action.url' => __('posts.form.google_business.cta_url'), + "{$prefix}.title" => __('posts.form.pinterest.title'), + "{$prefix}.description" => __('posts.form.youtube.description'), + "{$prefix}.link" => __('posts.form.pinterest.link'), + "{$prefix}.event.title" => __('posts.form.google_business.event_title'), + "{$prefix}.call_to_action.url" => __('posts.form.google_business.cta_url'), ]; } @@ -253,66 +221,46 @@ public static function documentation(): string } /** - * Adds validation errors for per-platform meta that becomes mandatory once a - * post is published or scheduled (TikTok privacy, Pinterest board, Discord - * channel), based on the submitted request platforms. The caller resolves each - * platform row to its Platform enum, since that lookup differs between create - * (by social account) and update (by post platform). - * - * @param array $platforms - * @param callable(mixed, int): (Platform|SocialAccount|null) $resolvePlatform + * Adds the validation error for meta that becomes mandatory once a post is + * published or scheduled (TikTok privacy, Pinterest board, Discord channel). */ - public static function addRequiredOnPublishErrors(Validator $validator, array $platforms, callable $resolvePlatform): void + public static function addRequiredOnPublishErrors(Validator $validator, Platform|SocialAccount|null $target, mixed $meta, string $prefix = 'meta'): void { - foreach ($platforms as $index => $platform) { - $violation = self::requiredMetaViolation($resolvePlatform($platform, $index), data_get($platform, 'meta')); + $violation = self::requiredMetaViolation($target, $meta); - if ($violation !== null) { - [$field, $message] = $violation; - $key = "platforms.{$index}.meta.{$field}"; + if ($violation === null) { + return; + } - if (! $validator->errors()->has($key)) { - $validator->errors()->add($key, $message); - } - } + [$field, $message] = $violation; + $key = "{$prefix}.{$field}"; + + if (! $validator->errors()->has($key)) { + $validator->errors()->add($key, $message); } } /** - * Asserts that every ENABLED platform already stored on a post has the meta it - * needs to publish. Used by entry points that publish a post's stored state - * without resubmitting platforms (e.g. the MCP publish tool), so a misconfigured - * post fails fast with a clear message instead of only at publish time. - * - * @param array $platformIds Submitted order for indexed errors. + * Asserts that the post's stored meta is enough to publish. Used by entry + * points that publish a post's stored state without resubmitting it (e.g. + * the MCP publish tool), so a misconfigured post fails fast with a clear + * message instead of only at publish time. * * @throws ValidationException */ - public static function assertStoredPostPublishable(Post $post, array $platformIds = []): void + public static function assertStoredPostPublishable(Post $post): void { - $platforms = $post->postPlatforms()->enabled()->with('socialAccount')->get()->values(); - - if ($platformIds !== []) { - $platformsById = $platforms->keyBy('id'); - $platforms = collect($platformIds) - ->map(fn (string $id): ?PostPlatform => $platformsById->get($id)) - ->filter(); + if (! $post->hasDestination()) { + return; } - $errors = []; + $violation = self::requiredMetaViolation($post->socialAccount ?? $post->platform, $post->meta) + ?? self::contentMetaViolation($post->platform, $post->meta, (string) $post->content); - foreach ($platforms as $index => $postPlatform) { - $violation = self::requiredMetaViolation($postPlatform->socialAccount ?? $postPlatform->platform, $postPlatform->meta) - ?? self::contentMetaViolation($postPlatform->platform, $postPlatform->meta, (string) $post->content); - - if ($violation !== null) { - [$field, $message] = $violation; - $errors["platforms.{$index}.meta.{$field}"] = $message; - } - } + if ($violation !== null) { + [$field, $message] = $violation; - if ($errors !== []) { - throw ValidationException::withMessages($errors); + throw ValidationException::withMessages(["meta.{$field}" => $message]); } } diff --git a/app/Support/PostStatusRules.php b/app/Support/PostStatusRules.php index b03b96fb04..dd28c65edc 100644 --- a/app/Support/PostStatusRules.php +++ b/app/Support/PostStatusRules.php @@ -30,7 +30,6 @@ class PostStatusRules */ private const EDIT_BLOCKED_STATUSES = [ PostStatus::Published, - PostStatus::PartiallyPublished, PostStatus::Failed, PostStatus::Publishing, ]; @@ -43,7 +42,6 @@ class PostStatusRules private const DELETE_BLOCKED_STATUSES = [ PostStatus::Publishing, PostStatus::Published, - PostStatus::PartiallyPublished, PostStatus::Failed, ]; @@ -123,8 +121,8 @@ public static function queueMessages(): array */ public static function assertStoredPostPublishable(Post $post): void { - if (! $post->postPlatforms()->enabled()->exists()) { - throw ValidationException::withMessages(['platforms' => __('validation.required', ['attribute' => 'platforms'])]); + if (! $post->hasChannel()) { + throw ValidationException::withMessages(['social_account_id' => __('posts.errors.choose_channel')]); } PostPlatformMetaRules::assertStoredPostPublishable($post); diff --git a/app/Support/Repurpose/DestinationMetaRules.php b/app/Support/Repurpose/DestinationMetaRules.php index 6a72e4d545..57a7121fc7 100644 --- a/app/Support/Repurpose/DestinationMetaRules.php +++ b/app/Support/Repurpose/DestinationMetaRules.php @@ -9,7 +9,6 @@ use App\Models\SocialAccount; use App\Support\PostPlatformMetaRules; use Illuminate\Support\Facades\Validator as ValidatorFacade; -use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; use Illuminate\Validation\Validator; @@ -21,7 +20,7 @@ class DestinationMetaRules public static function rules(): array { return [ - ...self::reKey(PostPlatformMetaRules::rules()), + ...PostPlatformMetaRules::rules('destinations.*.meta'), 'destinations.*.meta.thread_replies.*.media.*.url' => ['prohibited'], ]; } @@ -31,7 +30,7 @@ public static function rules(): array */ public static function messages(): array { - return self::reKey(PostPlatformMetaRules::messages()); + return PostPlatformMetaRules::messages('destinations.*.meta'); } /** @@ -39,7 +38,7 @@ public static function messages(): array */ public static function attributes(): array { - return self::reKey(PostPlatformMetaRules::attributes()); + return PostPlatformMetaRules::attributes('destinations.*.meta'); } public static function enforcedFor(Repurpose $repurpose): bool @@ -86,23 +85,4 @@ public static function assertRequired(array $destinations, ?string $workspaceId) throw new ValidationException($validator); } } - - /** - * @param array $entries - * @return array - */ - private static function reKey(array $entries): array - { - $destinations = []; - - foreach ($entries as $key => $entry) { - if (! Str::startsWith($key, 'platforms.*.meta')) { - continue; - } - - $destinations[Str::replaceFirst('platforms.*.', 'destinations.*.', $key)] = $entry; - } - - return $destinations; - } } diff --git a/app/Support/Requests/Post/PostRequestRules.php b/app/Support/Requests/Post/PostRequestRules.php index 8ecb976bc1..c1be1f097e 100644 --- a/app/Support/Requests/Post/PostRequestRules.php +++ b/app/Support/Requests/Post/PostRequestRules.php @@ -8,18 +8,16 @@ use App\Enums\Post\Status; use App\Enums\PostPlatform\ContentType; use App\Models\Post; -use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Models\Workspace; use App\Rules\ContentFitsPlatformLimits; use App\Rules\ContentTypeCompatibleWithMedia; use App\Rules\ContentTypeMatchesPlatform; -use App\Rules\ContentTypeMatchesPostPlatform; +use App\Rules\ContentTypeMatchesPostChannel; use App\Rules\PostContentFitsMaxLength; use App\Support\PostMediaRules; use App\Support\PostPlatformMetaRules; use App\Support\PostStatusRules; -use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Str; use Illuminate\Validation\Rule; @@ -40,7 +38,7 @@ class PostRequestRules */ public static function store(Workspace $workspace, array $input): array { - $platforms = (array) data_get($input, 'platforms', []); + $account = self::selectedAccounts($workspace, $input); return [ 'content' => [ @@ -50,26 +48,26 @@ public static function store(Workspace $workspace, array $input): array Rule::when( in_array(data_get($input, 'status'), [Status::Scheduled->value, Status::Publishing->value], true), [new ContentFitsPlatformLimits( - self::selectedAccounts($workspace, $input), - PostPlatformMetaRules::metaByKey($platforms, 'social_account_id'), - PostPlatformMetaRules::contentTypesByKey($platforms, 'social_account_id'), + $account, + $account->map(fn (): array => (array) data_get($input, 'meta', []))->all(), + $account->map(fn (): mixed => data_get($input, 'content_type'))->all(), )], ), ], ...PostMediaRules::rules(), - 'platforms' => ['required', 'array', 'size:1'], + 'platforms' => ['prohibited'], 'status' => ['sometimes', 'string', Rule::in(self::STATUSES)], - 'platforms.*.social_account_id' => [ + 'social_account_id' => [ 'required', 'uuid', Rule::exists('social_accounts', 'id')->where('workspace_id', $workspace->id), ], - 'platforms.*.content_type' => [ + 'content_type' => [ 'sometimes', 'nullable', 'string', Rule::in(array_column(ContentType::cases(), 'value')), - new ContentTypeMatchesPlatform, + new ContentTypeMatchesPlatform('social_account_id'), ], ...PostPlatformMetaRules::rules(), 'scheduled_at' => ['nullable', 'date', 'after:now', 'before:2038-01-19'], @@ -105,15 +103,13 @@ public static function batch(Workspace $workspace): array } /** - * Expects the input already passed through updateInput(). - * * @param array $input * @return array */ public static function update(Workspace $workspace, Post $post, array $input): array { $status = data_get($input, 'status'); - $platforms = (array) data_get($input, 'platforms', []); + $target = $post->socialAccount ?? $post->platform; return [ 'status' => ['sometimes', 'string', Rule::in(self::STATUSES)], @@ -124,23 +120,20 @@ public static function update(Workspace $workspace, Post $post, array $input): a Rule::when( in_array($status, [Status::Scheduled->value, Status::Publishing->value], true), [new ContentFitsPlatformLimits( - self::submittedTargets($post, $input), - PostPlatformMetaRules::metaByKey($platforms, 'id'), - PostPlatformMetaRules::contentTypesByKey($platforms, 'id', self::storedContentTypes($post)), + collect($target === null ? [] : ['post' => $target]), + ['post' => self::effectiveMeta($post, $input)], + ['post' => data_get($input, 'content_type') ?? $post->content_type?->value], )], ), ], ...PostMediaRules::rules(), 'social_account_id' => ['prohibited'], - 'content_type' => ['sometimes', 'string', Rule::in(array_column(ContentType::cases(), 'value'))], - 'meta' => ['sometimes', 'array'], - 'platforms' => ['sometimes', 'array'], - 'platforms.*.id' => ['required', 'uuid', Rule::exists('post_platforms', 'id')->where('post_id', $post->id)->where('enabled', true)], - 'platforms.*.content_type' => [ + 'platforms' => ['prohibited'], + 'content_type' => [ 'sometimes', 'string', Rule::in(array_column(ContentType::cases(), 'value')), - new ContentTypeMatchesPostPlatform, + new ContentTypeMatchesPostChannel($post), ], ...PostPlatformMetaRules::rules(), 'scheduled_at' => PostStatusRules::scheduledAtRules($post, $status, filled(data_get($input, 'queue'))), @@ -161,32 +154,6 @@ public static function publish(): array ]; } - /** - * The flat `content_type` / `meta` of a single-channel post rewritten as - * `platforms[0]` for its enabled destination, so both input shapes are - * validated by the same per-platform rules and report the same keys. - * - * @param array $input - * @return array - */ - public static function updateInput(Post $post, array $input): array - { - if (array_key_exists('platforms', $input) || ! Arr::hasAny($input, ['content_type', 'meta'])) { - return $input; - } - - $targets = $post->postPlatforms()->enabled()->pluck('id'); - - if ($targets->count() !== 1) { - return $input; - } - - return [ - ...Arr::except($input, ['content_type', 'meta']), - 'platforms' => [['id' => $targets->sole(), ...Arr::only($input, ['content_type', 'meta'])]], - ]; - } - /** * Required-on-publish meta and media compatibility checks of an update * that schedules or publishes, run after the field rules. @@ -201,35 +168,28 @@ public static function afterUpdate(Validator $validator, Post $post, array $inpu self::addMediaCompatibilityErrors($validator, $post, $input); - $targets = self::submittedTargets($post, $input); - - PostPlatformMetaRules::addRequiredOnPublishErrors( - $validator, - (array) data_get($input, 'platforms', []), - fn (mixed $platform) => $targets[data_get($platform, 'id')] ?? null, - ); + if (array_key_exists('meta', $input)) { + PostPlatformMetaRules::addRequiredOnPublishErrors($validator, $post->socialAccount ?? $post->platform, self::effectiveMeta($post, $input)); + } } /** - * The workspace accounts chosen in `platforms[].social_account_id`, keyed by id. + * The workspace account chosen in `social_account_id`, keyed by id. * * @param array $input * @return Collection */ public static function selectedAccounts(Workspace $workspace, array $input): Collection { - $accountIds = collect((array) data_get($input, 'platforms', [])) - ->pluck('social_account_id') - ->filter(fn (mixed $id): bool => is_string($id) && Str::isUuid($id)) - ->all(); + $accountId = data_get($input, 'social_account_id'); - if ($accountIds === []) { + if (! is_string($accountId) || ! Str::isUuid($accountId)) { return collect(); } return SocialAccount::query() ->where('workspace_id', $workspace->id) - ->whereIn('id', $accountIds) + ->whereKey($accountId) ->get() ->keyBy('id'); } @@ -273,7 +233,7 @@ private static function labelRules(Workspace $workspace): array */ private static function addMediaCompatibilityErrors(Validator $validator, Post $post, array $input): void { - if (filled(data_get($input, 'content_type')) && ! array_key_exists('platforms', $input)) { + if (filled(data_get($input, 'content_type'))) { return; } @@ -281,7 +241,7 @@ private static function addMediaCompatibilityErrors(Validator $validator, Post $ $entries = ContentTypeCompatibleWithMedia::entriesForUpdate( $post, - array_key_exists('platforms', $input) ? (array) data_get($input, 'platforms', []) : null, + null, array_key_exists('media', $input) ? $media : null, ); @@ -291,39 +251,13 @@ private static function addMediaCompatibilityErrors(Validator $validator, Post $ } /** - * The post's destinations named in `platforms[].id`, each resolved to its - * account (or its platform when the account is gone), keyed by id. + * The meta the update would store: the submitted keys over the stored ones. * * @param array $input - * @return Collection - */ - private static function submittedTargets(Post $post, array $input): Collection - { - $ids = collect((array) data_get($input, 'platforms', [])) - ->pluck('id') - ->filter(fn (mixed $id): bool => is_string($id) && Str::isUuid($id)) - ->all(); - - if ($ids === []) { - return collect(); - } - - return $post->postPlatforms() - ->whereIn('id', $ids) - ->with('socialAccount') - ->get() - ->mapWithKeys(fn (PostPlatform $postPlatform): array => [ - $postPlatform->id => $postPlatform->socialAccount ?? $postPlatform->platform, - ]); - } - - /** - * @return array + * @return array */ - private static function storedContentTypes(Post $post): array + private static function effectiveMeta(Post $post, array $input): array { - return $post->postPlatforms()->pluck('content_type', 'id') - ->map(fn (?ContentType $contentType): ?string => $contentType?->value) - ->all(); + return [...($post->meta ?? []), ...(array) data_get($input, 'meta', [])]; } } diff --git a/app/Support/Social/AbandonGoogleBusinessReview.php b/app/Support/Social/AbandonGoogleBusinessReview.php index 948f91c33e..6ea214f628 100644 --- a/app/Support/Social/AbandonGoogleBusinessReview.php +++ b/app/Support/Social/AbandonGoogleBusinessReview.php @@ -5,33 +5,33 @@ namespace App\Support\Social; use App\Actions\Post\FinalizePostPublication; -use App\Enums\PostPlatform\Status; -use App\Events\PostPlatformStatusUpdated; -use App\Models\PostPlatform; +use App\Enums\Post\PublishStatus; +use App\Events\PostStatusUpdated; +use App\Models\Post; /** * Stops waiting on a Google Business review we can no longer settle remotely - * (disconnected account, switched-off target, recover sweep). Rejects the - * row, prunes the JPEG, and lets FinalizePostPublication close the parent. + * (disconnected account, recover sweep). Rejects the publication, prunes the + * JPEG, and lets FinalizePostPublication close the post. */ class AbandonGoogleBusinessReview { /** * @param array $errorContext */ - public static function execute(PostPlatform $postPlatform, string $errorMessage, array $errorContext = []): void + public static function execute(Post $post, string $errorMessage, array $errorContext = []): void { - if ($postPlatform->status === Status::PendingReview) { - $postPlatform->markAsRejected( - (string) $postPlatform->platform_post_id, - $postPlatform->platform_url, + if ($post->publish_status === PublishStatus::PendingReview) { + $post->markPublicationRejected( + (string) $post->platform_post_id, + $post->platform_url, $errorMessage, $errorContext, ); } - app(GoogleBusinessDerivativeCleaner::class)->cleanup($postPlatform->id); - app(FinalizePostPublication::class)->handle($postPlatform->post); - PostPlatformStatusUpdated::dispatch($postPlatform->fresh()); + app(GoogleBusinessDerivativeCleaner::class)->cleanup($post); + app(FinalizePostPublication::class)->handle($post); + PostStatusUpdated::dispatch($post->fresh()); } } diff --git a/app/Support/Social/GoogleBusinessDerivativeCleaner.php b/app/Support/Social/GoogleBusinessDerivativeCleaner.php index 0148d85999..96807449f3 100644 --- a/app/Support/Social/GoogleBusinessDerivativeCleaner.php +++ b/app/Support/Social/GoogleBusinessDerivativeCleaner.php @@ -4,6 +4,7 @@ namespace App\Support\Social; +use App\Models\Post; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; use Throwable; @@ -12,20 +13,25 @@ class GoogleBusinessDerivativeCleaner { public const string DIRECTORY = 'google-business-derivatives'; - public static function pathFor(string $postPlatformId): string + /** + * The JPEG is named after the post. A post published before posts and + * their destinations were merged keeps its destination's id until its + * file is gone. + */ + public static function pathFor(Post $post): string { - return self::DIRECTORY."/{$postPlatformId}.jpg"; + return self::DIRECTORY.'/'.($post->legacy_target_id ?? $post->id).'.jpg'; } - public function cleanup(string $postPlatformId): void + public function cleanup(Post $post): void { - $path = self::pathFor($postPlatformId); + $path = self::pathFor($post); try { Storage::delete($path); } catch (Throwable $e) { Log::warning('Failed to prune Google Business Profile image derivative', [ - 'post_platform_id' => $postPlatformId, + 'post_id' => $post->id, 'path' => $path, 'error' => $e->getMessage(), ]); diff --git a/app/Support/Social/PublishCheckpoint.php b/app/Support/Social/PublishCheckpoint.php index fa11a4954e..773bf7cb48 100644 --- a/app/Support/Social/PublishCheckpoint.php +++ b/app/Support/Social/PublishCheckpoint.php @@ -6,7 +6,7 @@ /** * Shared keys and readers for in-flight publish checkpoints on - * PostPlatform.error_context. Used by the publishers, the TikTok + * posts.error_context. Used by the publishers, the TikTok * derivative cleaner, and posts:retry. */ final class PublishCheckpoint diff --git a/app/Support/Social/ThreadProgress.php b/app/Support/Social/ThreadProgress.php index 302878a49b..012dbba6fb 100644 --- a/app/Support/Social/ThreadProgress.php +++ b/app/Support/Social/ThreadProgress.php @@ -4,12 +4,12 @@ namespace App\Support\Social; -use App\Models\PostPlatform; +use App\Models\Post; use App\Support\ThreadReplies; /** * Segments of a thread already live on the network, kept in - * PostPlatform.error_context so any retry resumes instead of re-posting. + * posts.error_context so any retry resumes instead of re-posting. */ final class ThreadProgress { @@ -42,10 +42,10 @@ public static function rootHash(?array $context): ?string * * @param array|null $context */ - public static function failureMessage(PostPlatform $postPlatform, string $message, ?array $context): string + public static function failureMessage(Post $post, string $message, ?array $context): string { $published = count((array) data_get($context, self::KEY, [])); - $total = 1 + (ThreadReplies::supports($postPlatform->platform) ? count(ThreadReplies::of($postPlatform->meta)) : 0); + $total = 1 + (ThreadReplies::supports($post->platform) ? count(ThreadReplies::of($post->meta)) : 0); return $published > 0 && $published < $total ? __('posts.errors.thread_incomplete', ['published' => $published, 'total' => $total, 'error' => $message]) @@ -78,10 +78,10 @@ public static function resumable(?array $context, array $hashes): array /** * @param list> $posted */ - public static function remember(PostPlatform $postPlatform, array $posted): void + public static function remember(Post $post, array $posted): void { - $postPlatform->forceFill([ - 'error_context' => [...($postPlatform->error_context ?? []), self::KEY => $posted], - ])->save(); + $post->writePublication([ + 'error_context' => [...($post->error_context ?? []), self::KEY => $posted], + ]); } } diff --git a/app/Support/Social/TikTokPhotoDerivativeCleaner.php b/app/Support/Social/TikTokPhotoDerivativeCleaner.php index 1ff2e4af39..37ed7663bb 100644 --- a/app/Support/Social/TikTokPhotoDerivativeCleaner.php +++ b/app/Support/Social/TikTokPhotoDerivativeCleaner.php @@ -16,9 +16,9 @@ class TikTokPhotoDerivativeCleaner /** * @param array|null $context */ - public function cleanup(?array $context, ?string $postPlatformId = null): void + public function cleanup(?array $context, ?string $postId = null): void { - $this->cleanupPaths(PublishCheckpoint::tiktokDerivativePaths($context), $postPlatformId); + $this->cleanupPaths(PublishCheckpoint::tiktokDerivativePaths($context), $postId); } /** @@ -26,19 +26,19 @@ public function cleanup(?array $context, ?string $postPlatformId = null): void * * @param array|null $context */ - public function cleanupUnlessPublishInFlight(?array $context, ?string $postPlatformId = null): void + public function cleanupUnlessPublishInFlight(?array $context, ?string $postId = null): void { if (PublishCheckpoint::tiktokPublishId($context) !== null) { return; } - $this->cleanup($context, $postPlatformId); + $this->cleanup($context, $postId); } /** * @param array $paths */ - public function cleanupPaths(array $paths, ?string $postPlatformId = null): void + public function cleanupPaths(array $paths, ?string $postId = null): void { $derivativePaths = array_values(array_filter( $paths, @@ -53,7 +53,7 @@ public function cleanupPaths(array $paths, ?string $postPlatformId = null): void Storage::delete($derivativePaths); } catch (Throwable $e) { Log::warning('Failed to prune TikTok photo derivatives', [ - 'post_platform_id' => $postPlatformId, + 'post_id' => $postId, 'error' => $e->getMessage(), ]); } diff --git a/database/factories/PostFactory.php b/database/factories/PostFactory.php index 911e146f65..61e6ed4e1e 100644 --- a/database/factories/PostFactory.php +++ b/database/factories/PostFactory.php @@ -5,14 +5,23 @@ namespace Database\Factories; use App\Enums\Post\Origin; +use App\Enums\Post\PublishStatus; use App\Enums\Post\ScheduleMode; use App\Enums\Post\Status as PostStatus; +use App\Enums\PostPlatform\ContentType; +use App\Enums\SocialAccount\Platform; +use App\Enums\TikTok\PrivacyLevel; use App\Models\Post; +use App\Models\SocialAccount; use App\Models\User; use App\Models\Workspace; use Illuminate\Database\Eloquent\Factories\Factory; /** + * A post without a channel is a draft that was never sent anywhere. Use + * forAccount() or a network state (linkedin(), x(), ...) for a post with a + * channel; a network state creates the channel in the post's workspace. + * * @extends Factory */ class PostFactory extends Factory @@ -33,6 +42,17 @@ public function definition(): array ]; } + public function forAccount(SocialAccount $account, ?ContentType $contentType = null): static + { + return $this->state(fn (array $attributes) => [ + 'workspace_id' => $account->workspace_id, + 'social_account_id' => $account->id, + 'platform' => $account->platform, + 'content_type' => $contentType ?? ContentType::defaultFor($account->platform), + 'meta' => $attributes['meta'] ?? [], + ]); + } + public function draft(): static { return $this->state(fn (array $attributes) => [ @@ -59,10 +79,21 @@ public function pendingApproval(): static ]); } + public function publishing(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => PostStatus::Publishing, + 'publish_status' => PublishStatus::Publishing, + ]); + } + public function published(): static { return $this->state(fn (array $attributes) => [ 'status' => PostStatus::Published, + 'publish_status' => PublishStatus::Published, + 'platform_post_id' => $this->faker->uuid(), + 'platform_url' => $this->faker->url(), 'published_at' => now(), ]); } @@ -73,6 +104,8 @@ public function imported(): static 'origin' => Origin::Network, 'user_id' => null, 'status' => PostStatus::Published, + 'publish_status' => PublishStatus::Published, + 'platform_post_id' => $this->faker->uuid(), 'published_at' => now(), ]); } @@ -81,6 +114,117 @@ public function failed(): static { return $this->state(fn (array $attributes) => [ 'status' => PostStatus::Failed, + 'publish_status' => PublishStatus::Failed, + 'error_message' => 'Failed to publish', + ]); + } + + public function pendingReview(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => PostStatus::Publishing, + 'publish_status' => PublishStatus::PendingReview, + 'submitted_at' => now(), + ]); + } + + public function linkedin(): static + { + return $this->onNetwork(Platform::LinkedIn, ContentType::LinkedInPost, 'linkedin'); + } + + public function x(): static + { + return $this->onNetwork(Platform::X, ContentType::XPost, 'x'); + } + + public function instagram(): static + { + return $this->onNetwork(Platform::Instagram, ContentType::InstagramFeed, 'instagram'); + } + + public function bluesky(): static + { + return $this->onNetwork(Platform::Bluesky, ContentType::BlueskyPost, 'bluesky'); + } + + public function mastodon(): static + { + return $this->onNetwork(Platform::Mastodon, ContentType::MastodonPost, 'mastodon'); + } + + public function threads(): static + { + return $this->onNetwork(Platform::Threads, ContentType::ThreadsPost, 'threads'); + } + + public function tiktok(): static + { + return $this->onNetwork(Platform::TikTok, ContentType::TikTokVideo, 'tiktok', ['privacy_level' => PrivacyLevel::SelfOnly->value]); + } + + public function youtube(): static + { + return $this->onNetwork(Platform::YouTube, ContentType::YouTubeShort, 'youtube'); + } + + public function pinterest(): static + { + return $this->onNetwork(Platform::Pinterest, ContentType::PinterestPin, 'pinterest'); + } + + public function pinterestVideoPin(): static + { + return $this->onNetwork(Platform::Pinterest, ContentType::PinterestVideoPin, 'pinterest'); + } + + public function pinterestCarousel(): static + { + return $this->onNetwork(Platform::Pinterest, ContentType::PinterestCarousel, 'pinterest'); + } + + public function googleBusiness(): static + { + return $this->onNetwork(Platform::GoogleBusiness, ContentType::GoogleBusinessPost, 'googleBusiness'); + } + + public function facebook(): static + { + return $this->onNetwork(Platform::Facebook, ContentType::FacebookPost, 'facebook'); + } + + public function facebookReel(): static + { + return $this->onNetwork(Platform::Facebook, ContentType::FacebookReel, 'facebook'); + } + + public function facebookStory(): static + { + return $this->onNetwork(Platform::Facebook, ContentType::FacebookStory, 'facebook'); + } + + public function discord(): static + { + return $this->onNetwork(Platform::Discord, ContentType::DiscordMessage, 'discord'); + } + + public function telegram(): static + { + return $this->onNetwork(Platform::Telegram, ContentType::defaultFor(Platform::Telegram), 'telegram'); + } + + /** + * @param array $meta + */ + private function onNetwork(Platform $platform, ContentType $contentType, string $accountState, array $meta = []): static + { + return $this->state(fn (array $attributes) => [ + 'platform' => $platform, + 'content_type' => $contentType, + 'meta' => [...$meta, ...($attributes['meta'] ?? [])], + 'social_account_id' => isset($attributes['social_account_id']) + ? $attributes['social_account_id'] + : fn (array $resolved) => SocialAccount::factory()->{$accountState}()->state(['workspace_id' => $resolved['workspace_id']]), ]); } } diff --git a/database/factories/PostPlatformFactory.php b/database/factories/PostPlatformFactory.php deleted file mode 100644 index 72da643ee2..0000000000 --- a/database/factories/PostPlatformFactory.php +++ /dev/null @@ -1,198 +0,0 @@ - - */ -class PostPlatformFactory extends Factory -{ - /** - * Define the model's default state. - * - * @return array - */ - public function definition(): array - { - return [ - 'post_id' => Post::factory(), - 'social_account_id' => SocialAccount::factory(), - 'enabled' => true, - 'platform' => Platform::LinkedIn, - 'content_type' => ContentType::LinkedInPost, - 'status' => Status::Pending, - 'meta' => [], - ]; - } - - public function disabled(): static - { - return $this->state(fn (array $attributes) => [ - 'enabled' => false, - ]); - } - - public function published(): static - { - return $this->state(fn (array $attributes) => [ - 'status' => Status::Published, - 'platform_post_id' => $this->faker->uuid(), - 'platform_url' => $this->faker->url(), - 'published_at' => now(), - ]); - } - - public function failed(): static - { - return $this->state(fn (array $attributes) => [ - 'status' => Status::Failed, - 'error_message' => 'Failed to publish', - ]); - } - - public function pendingReview(): static - { - return $this->state(fn (array $attributes) => [ - 'status' => Status::PendingReview, - 'submitted_at' => now(), - ]); - } - - public function linkedin(): static - { - return $this->state(fn (array $attributes) => [ - 'platform' => Platform::LinkedIn, - ]); - } - - public function x(): static - { - return $this->state(fn (array $attributes) => [ - 'platform' => Platform::X, - 'content_type' => ContentType::XPost, - ]); - } - - public function instagram(): static - { - return $this->state(fn (array $attributes) => [ - 'platform' => Platform::Instagram, - ]); - } - - public function bluesky(): static - { - return $this->state(fn (array $attributes) => [ - 'platform' => Platform::Bluesky, - 'content_type' => ContentType::BlueskyPost, - ]); - } - - public function mastodon(): static - { - return $this->state(fn (array $attributes) => [ - 'platform' => Platform::Mastodon, - 'content_type' => ContentType::MastodonPost, - ]); - } - - public function threads(): static - { - return $this->state(fn (array $attributes) => [ - 'platform' => Platform::Threads, - 'content_type' => ContentType::ThreadsPost, - ]); - } - - public function tiktok(): static - { - return $this->state(fn (array $attributes) => [ - 'platform' => Platform::TikTok, - 'content_type' => ContentType::TikTokVideo, - 'meta' => ['privacy_level' => PrivacyLevel::SelfOnly->value], - ]); - } - - public function youtube(): static - { - return $this->state(fn (array $attributes) => [ - 'platform' => Platform::YouTube, - 'content_type' => ContentType::YouTubeShort, - ]); - } - - public function pinterest(): static - { - return $this->state(fn (array $attributes) => [ - 'platform' => Platform::Pinterest, - 'content_type' => ContentType::PinterestPin, - ]); - } - - public function googleBusiness(): static - { - return $this->state(fn (array $attributes) => [ - 'platform' => Platform::GoogleBusiness, - 'content_type' => ContentType::GoogleBusinessPost, - ]); - } - - public function pinterestVideoPin(): static - { - return $this->state(fn (array $attributes) => [ - 'platform' => Platform::Pinterest, - 'content_type' => ContentType::PinterestVideoPin, - ]); - } - - public function pinterestCarousel(): static - { - return $this->state(fn (array $attributes) => [ - 'platform' => Platform::Pinterest, - 'content_type' => ContentType::PinterestCarousel, - ]); - } - - public function facebook(): static - { - return $this->state(fn (array $attributes) => [ - 'platform' => Platform::Facebook, - 'content_type' => ContentType::FacebookPost, - ]); - } - - public function discord(): static - { - return $this->state(fn (array $attributes) => [ - 'platform' => Platform::Discord, - 'content_type' => ContentType::DiscordMessage, - ]); - } - - public function facebookReel(): static - { - return $this->state(fn (array $attributes) => [ - 'platform' => Platform::Facebook, - 'content_type' => ContentType::FacebookReel, - ]); - } - - public function facebookStory(): static - { - return $this->state(fn (array $attributes) => [ - 'platform' => Platform::Facebook, - 'content_type' => ContentType::FacebookStory, - ]); - } -} diff --git a/database/migrations/2026_10_08_222940_prepare_post_platforms_for_merge_into_posts.php b/database/migrations/2026_10_08_222940_prepare_post_platforms_for_merge_into_posts.php new file mode 100644 index 0000000000..762e87b00b --- /dev/null +++ b/database/migrations/2026_10_08_222940_prepare_post_platforms_for_merge_into_posts.php @@ -0,0 +1,116 @@ +where('enabled', false) + ->where('status', '!=', 'published') + ->delete(); + + $this->settlePartiallyPublishedPosts(); + $this->turnPostsWithoutDestinationIntoDrafts(); + $this->assertEveryPostHasOneDestination(); + } + + private function settlePartiallyPublishedPosts(): void + { + DB::table('posts') + ->where('status', 'partially_published') + ->whereIn('id', $this->postsWithTargets(exactly: 1)) + ->select('id') + ->chunkById(500, function ($posts): void { + foreach ($posts as $post) { + $target = DB::table('post_platforms')->where('post_id', $post->id)->first(['status', 'published_at']); + + $status = match ($target?->status) { + 'published' => 'published', + 'failed', 'rejected' => 'failed', + default => null, + }; + + if ($status === null) { + continue; + } + + DB::table('posts')->where('id', $post->id)->update([ + 'status' => $status, + 'published_at' => $target->published_at, + ]); + } + }); + } + + private function turnPostsWithoutDestinationIntoDrafts(): void + { + DB::table('posts') + ->whereIn('status', ['scheduled', 'pending_approval', 'publishing', 'failed']) + ->whereNotIn('id', DB::table('post_platforms')->select('post_id')) + ->update([ + 'status' => 'draft', + 'schedule_mode' => null, + 'published_at' => null, + 'approval_requested_at' => null, + 'approval_requested_by' => null, + 'approval_queue_position' => null, + 'recurrence_interval' => null, + 'recurrence_frequency' => null, + 'recurrence_remaining' => null, + 'recurrence_anchor_at' => null, + 'recurrence_origin_at' => null, + ]); + } + + private function assertEveryPostHasOneDestination(): void + { + $blockers = array_filter([ + 'posts with more than one destination' => DB::table('posts')->whereIn('id', $this->postsWithTargets(moreThan: 1))->count(), + 'disabled destinations that published' => DB::table('post_platforms')->where('enabled', false)->count(), + 'partially published posts with a destination still in flight' => DB::table('posts')->where('status', 'partially_published')->count(), + 'published posts without a destination' => DB::table('posts')->where('status', 'published')->whereNotIn('id', DB::table('post_platforms')->select('post_id'))->count(), + 'destinations whose account belongs to another workspace' => DB::table('post_platforms') + ->join('posts', 'posts.id', '=', 'post_platforms.post_id') + ->join('social_accounts', 'social_accounts.id', '=', 'post_platforms.social_account_id') + ->whereColumn('social_accounts.workspace_id', '!=', 'posts.workspace_id') + ->count(), + ]); + + if ($blockers === []) { + return; + } + + $found = collect($blockers)->map(fn (int $count, string $what): string => "{$count} {$what}")->implode(', '); + + throw new RuntimeException("Posts cannot be merged with their destinations yet ({$found}). Upgrade to TryPost v2.0.0 and run `php artisan release:trypost-2` first; fix any row it leaves by hand (in flight destinations settle on their own), then run the migrations again."); + } + + private function postsWithTargets(?int $exactly = null, ?int $moreThan = null): Builder + { + return DB::table('post_platforms') + ->select('post_id') + ->groupBy('post_id') + ->when($exactly !== null, fn (Builder $query) => $query->havingRaw('count(*) = ?', [$exactly])) + ->when($moreThan !== null, fn (Builder $query) => $query->havingRaw('count(*) > ?', [$moreThan])); + } +}; diff --git a/database/migrations/2026_10_08_222942_add_target_columns_to_posts_table.php b/database/migrations/2026_10_08_222942_add_target_columns_to_posts_table.php new file mode 100644 index 0000000000..b9a4763377 --- /dev/null +++ b/database/migrations/2026_10_08_222942_add_target_columns_to_posts_table.php @@ -0,0 +1,99 @@ +uuid('social_account_id')->nullable(); + $table->string('publish_status')->default(PublishStatus::DEFAULT->value); + $table->string('platform')->nullable(); + $table->string('content_type')->nullable(); + $table->string('platform_name')->nullable(); + $table->string('platform_username')->nullable(); + $table->text('platform_avatar')->nullable(); + $table->json('meta')->nullable(); + $table->string('platform_post_id')->nullable(); + $table->text('platform_url')->nullable(); + $table->text('error_message')->nullable(); + $table->json('error_context')->nullable(); + $table->json('thread_reply_ids')->nullable(); + $table->timestamp('submitted_at')->nullable(); + $table->timestamp('last_reconciled_at')->nullable(); + $table->timestamp('connection_warning_sent_at')->nullable(); + $table->timestamp('retry_at')->nullable(); + $table->timestamp('publication_updated_at')->nullable(); + $table->boolean('scheduled_before_media_checks')->default(false); + $table->uuid('legacy_target_id')->nullable()->unique(); + + $table->index(['social_account_id', 'status', 'scheduled_at']); + $table->index(['social_account_id', 'publish_status', 'published_at']); + $table->index(['social_account_id', 'platform_post_id']); + $table->index('platform_post_id'); + $table->index(['publish_status', 'last_reconciled_at']); + $table->index(['publish_status', 'retry_at']); + $table->index(['workspace_id', 'publish_status', 'published_at']); + }); + + Schema::table('posts', function (Blueprint $table): void { + $table->foreign('social_account_id')->references('id')->on('social_accounts')->nullOnDelete(); + }); + + Schema::table('analytics_publications', function (Blueprint $table): void { + $table->foreignUuid('post_id')->nullable()->unique()->constrained()->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('analytics_publications', function (Blueprint $table): void { + $table->dropForeign(['post_id']); + $table->dropUnique(['post_id']); + $table->dropColumn('post_id'); + }); + + Schema::table('posts', function (Blueprint $table): void { + $table->dropForeign(['social_account_id']); + }); + + Schema::table('posts', function (Blueprint $table): void { + $table->dropIndex(['social_account_id', 'status', 'scheduled_at']); + $table->dropIndex(['social_account_id', 'publish_status', 'published_at']); + $table->dropIndex(['social_account_id', 'platform_post_id']); + $table->dropIndex(['platform_post_id']); + $table->dropIndex(['publish_status', 'last_reconciled_at']); + $table->dropIndex(['publish_status', 'retry_at']); + $table->dropIndex(['workspace_id', 'publish_status', 'published_at']); + $table->dropUnique(['legacy_target_id']); + $table->dropColumn([ + 'social_account_id', + 'publish_status', + 'platform', + 'content_type', + 'platform_name', + 'platform_username', + 'platform_avatar', + 'meta', + 'platform_post_id', + 'platform_url', + 'error_message', + 'error_context', + 'thread_reply_ids', + 'submitted_at', + 'last_reconciled_at', + 'connection_warning_sent_at', + 'retry_at', + 'publication_updated_at', + 'scheduled_before_media_checks', + 'legacy_target_id', + ]); + }); + } +}; diff --git a/database/migrations/2026_10_08_222943_backfill_posts_from_post_platforms.php b/database/migrations/2026_10_08_222943_backfill_posts_from_post_platforms.php new file mode 100644 index 0000000000..cb96edd9c2 --- /dev/null +++ b/database/migrations/2026_10_08_222943_backfill_posts_from_post_platforms.php @@ -0,0 +1,210 @@ +clearCopiesOfVanishedTargets(); + $this->copyTargets(); + $this->linkAnalyticsPublications(); + $this->assertParity(); + $this->replacePartiallyPublishedWebhookEvent(); + } + + private function clearCopiesOfVanishedTargets(): void + { + DB::table('posts') + ->whereNotNull('legacy_target_id') + ->whereNotIn('legacy_target_id', DB::table('post_platforms')->select('id')) + ->update([ + ...array_fill_keys(self::TARGET_COLUMNS, null), + 'scheduled_before_media_checks' => false, + 'publish_status' => 'pending', + 'publication_updated_at' => null, + 'legacy_target_id' => null, + ]); + } + + private function copyTargets(): void + { + DB::table('post_platforms') + ->join('posts', 'posts.id', '=', 'post_platforms.post_id') + ->select([ + 'post_platforms.id as pp_id', + 'post_platforms.post_id as pp_post_id', + 'post_platforms.status as pp_status', + 'post_platforms.published_at as pp_published_at', + 'post_platforms.updated_at as pp_updated_at', + 'posts.published_at as p_published_at', + ...array_map(fn (string $column): string => "post_platforms.{$column} as pp_{$column}", self::TARGET_COLUMNS), + ]) + ->chunkById(500, function ($targets): void { + DB::transaction(function () use ($targets): void { + foreach ($targets as $target) { + DB::table('posts')->where('id', $target->pp_post_id)->update([ + ...collect(self::TARGET_COLUMNS)->mapWithKeys(fn (string $column): array => [$column => $target->{"pp_{$column}"}])->all(), + 'publish_status' => $target->pp_status, + 'published_at' => $target->pp_published_at ?? ($target->pp_status === 'published' ? $target->p_published_at : null), + 'publication_updated_at' => $target->pp_updated_at, + 'legacy_target_id' => $target->pp_id, + ]); + } + }); + }, 'post_platforms.id', 'pp_id'); + } + + private function linkAnalyticsPublications(): void + { + DB::table('analytics_publications') + ->whereNotNull('post_platform_id') + ->select(['id', 'post_platform_id']) + ->chunkById(500, function ($publications): void { + DB::transaction(function () use ($publications): void { + $postIds = DB::table('post_platforms') + ->whereIn('id', $publications->pluck('post_platform_id')) + ->pluck('post_id', 'id'); + + foreach ($publications as $publication) { + DB::table('analytics_publications') + ->where('id', $publication->id) + ->update(['post_id' => $postIds[$publication->post_platform_id] ?? null]); + } + }); + }); + } + + private function assertParity(): void + { + $targets = DB::table('post_platforms')->count(); + $copies = DB::table('posts')->whereNotNull('legacy_target_id')->count(); + + if ($targets !== $copies) { + throw new RuntimeException("Backfill parity failed: {$targets} destinations, {$copies} posts carry one."); + } + + $scalarColumns = array_values(array_diff(self::TARGET_COLUMNS, self::JSON_COLUMNS)); + + DB::table('post_platforms') + ->join('posts', 'posts.legacy_target_id', '=', 'post_platforms.id') + ->select([ + 'post_platforms.id as pp_id', + 'post_platforms.post_id as pp_post_id', + 'posts.id as p_id', + 'post_platforms.status as pp_status', + 'posts.publish_status as p_publish_status', + ...array_map(fn (string $column): string => "post_platforms.{$column} as pp_{$column}", self::TARGET_COLUMNS), + ...array_map(fn (string $column): string => "posts.{$column} as p_{$column}", self::TARGET_COLUMNS), + ]) + ->chunkById(500, function ($rows) use ($scalarColumns): void { + foreach ($rows as $row) { + $mismatch = $row->pp_post_id !== $row->p_id || $row->pp_status !== $row->p_publish_status + || collect($scalarColumns)->contains(fn (string $column): bool => ! $this->sameScalar($row->{"pp_{$column}"}, $row->{"p_{$column}"})) + || collect(self::JSON_COLUMNS)->contains(fn (string $column): bool => ! $this->sameJson($row->{"pp_{$column}"}, $row->{"p_{$column}"})); + + if ($mismatch) { + throw new RuntimeException("Backfill parity failed for destination {$row->pp_id}."); + } + } + }, 'post_platforms.id', 'pp_id'); + + $crossWorkspace = DB::table('posts') + ->join('social_accounts', 'social_accounts.id', '=', 'posts.social_account_id') + ->whereColumn('social_accounts.workspace_id', '!=', 'posts.workspace_id') + ->count(); + + $crossWorkspaceAnalytics = DB::table('analytics_publications') + ->join('posts', 'posts.id', '=', 'analytics_publications.post_id') + ->whereColumn('analytics_publications.workspace_id', '!=', 'posts.workspace_id') + ->count(); + + if ($crossWorkspace > 0 || $crossWorkspaceAnalytics > 0) { + throw new RuntimeException("Backfill parity failed: {$crossWorkspace} posts and {$crossWorkspaceAnalytics} publications point outside their workspace."); + } + } + + private function sameScalar(mixed $left, mixed $right): bool + { + if ($left === null || $right === null) { + return $left === $right; + } + + return (string) $left === (string) $right; + } + + private function sameJson(mixed $left, mixed $right): bool + { + $decode = fn (mixed $value): mixed => is_string($value) ? json_decode($value, true) : $value; + + return $this->normalize($decode($left)) == $this->normalize($decode($right)); + } + + private function normalize(mixed $value): mixed + { + if (! is_array($value)) { + return $value; + } + + if (! array_is_list($value)) { + ksort($value); + } + + return array_map($this->normalize(...), $value); + } + + private function replacePartiallyPublishedWebhookEvent(): void + { + DB::table('webhooks') + ->select(['id', 'events']) + ->chunkById(500, function ($webhooks): void { + foreach ($webhooks as $webhook) { + $events = json_decode((string) $webhook->events, true); + + if (! is_array($events) || ! in_array('post.partially_published', $events, true)) { + continue; + } + + $replaced = array_values(array_unique([ + ...array_filter($events, fn (mixed $event): bool => $event !== 'post.partially_published'), + 'post.published', + 'post.failed', + ])); + + DB::table('webhooks')->where('id', $webhook->id)->update(['events' => json_encode($replaced)]); + } + }); + } +}; diff --git a/database/migrations/2026_10_08_222944_drop_post_platforms_table.php b/database/migrations/2026_10_08_222944_drop_post_platforms_table.php new file mode 100644 index 0000000000..daeed55ffd --- /dev/null +++ b/database/migrations/2026_10_08_222944_drop_post_platforms_table.php @@ -0,0 +1,30 @@ +whereNotIn('id', DB::table('posts')->whereNotNull('legacy_target_id')->select('legacy_target_id')) + ->count(); + + if ($unmirrored > 0) { + throw new RuntimeException("{$unmirrored} destinations were not copied to their posts; post_platforms was kept."); + } + + Schema::table('analytics_publications', function (Blueprint $table): void { + $table->dropForeign(['post_platform_id']); + $table->dropUnique(['post_platform_id']); + $table->dropColumn('post_platform_id'); + }); + + Schema::dropIfExists('post_platforms'); + } +}; diff --git a/routes/app.php b/routes/app.php index 4a1a02dfad..9f4d61c1cd 100644 --- a/routes/app.php +++ b/routes/app.php @@ -224,7 +224,7 @@ Route::get('posts/create', [PostController::class, 'create'])->name('app.posts.create'); Route::post('posts', [PostController::class, 'store'])->name('app.posts.store'); Route::get('posts/{post}/edit', [PostController::class, 'edit'])->name('app.posts.edit'); - Route::get('posts/{post}/platforms/{postPlatform}/metrics', [PostController::class, 'platformMetrics'])->name('app.posts.platforms.metrics'); + Route::get('posts/{post}/metrics', [PostController::class, 'metrics'])->name('app.posts.metrics'); Route::put('posts/{post}', [PostController::class, 'update'])->name('app.posts.update'); Route::put('posts/{post}/schedule', [PostScheduleController::class, 'update'])->name('app.posts.schedule.update'); Route::put('posts/{post}/approve', [PostApprovalController::class, 'approve'])->name('app.posts.approve'); diff --git a/tests/Browser/PublishQueueTest.php b/tests/Browser/PublishQueueTest.php index 37ab5ea9c8..5fa4a6cace 100644 --- a/tests/Browser/PublishQueueTest.php +++ b/tests/Browser/PublishQueueTest.php @@ -4,11 +4,11 @@ use App\Actions\Post\CreatePosts; use App\Actions\Post\Queue\ReflowChannelQueue; +use App\Enums\Post\PublishStatus as PostPlatformStatus; use App\Enums\Post\QueuePosition; use App\Enums\Post\ScheduleMode; use App\Enums\Post\Status as PostStatus; use App\Enums\PostPlatform\ContentType; -use App\Enums\PostPlatform\Status as PostPlatformStatus; use App\Enums\User\TimeFormat; use App\Exceptions\Social\ErrorCategory; use App\Models\Post; diff --git a/tests/Feature/Actions/Post/DuplicatePostTest.php b/tests/Feature/Actions/Post/DuplicatePostTest.php index 59453ffe05..1896dd8efa 100644 --- a/tests/Feature/Actions/Post/DuplicatePostTest.php +++ b/tests/Feature/Actions/Post/DuplicatePostTest.php @@ -4,8 +4,8 @@ use App\Actions\Post\DuplicatePost; use App\Enums\Post\CreatedVia; +use App\Enums\Post\PublishStatus as PostPlatformStatus; use App\Enums\Post\Status as PostStatus; -use App\Enums\PostPlatform\Status as PostPlatformStatus; use App\Enums\SocialAccount\Platform; use App\Events\PostCreated; use App\Models\Post; diff --git a/tests/Feature/Actions/Post/FinalizePostPublicationTest.php b/tests/Feature/Actions/Post/FinalizePostPublicationTest.php index 39bdd9ff4d..33b701850d 100644 --- a/tests/Feature/Actions/Post/FinalizePostPublicationTest.php +++ b/tests/Feature/Actions/Post/FinalizePostPublicationTest.php @@ -4,8 +4,8 @@ use App\Actions\Post\FinalizePostPublication; use App\Enums\Notification\Type; +use App\Enums\Post\PublishStatus as PostPlatformStatus; use App\Enums\Post\Status as PostStatus; -use App\Enums\PostPlatform\Status as PostPlatformStatus; use App\Enums\User\Locale; use App\Jobs\SendNotification; use App\Mail\PostPublished; diff --git a/tests/Feature/Actions/Post/UpdatePostTest.php b/tests/Feature/Actions/Post/UpdatePostTest.php index 5532d4f5fa..2c02626d1b 100644 --- a/tests/Feature/Actions/Post/UpdatePostTest.php +++ b/tests/Feature/Actions/Post/UpdatePostTest.php @@ -5,8 +5,8 @@ use App\Actions\Post\CreatePosts; use App\Actions\Post\UpdatePost; use App\Enums\Post\Action as PostAction; +use App\Enums\Post\PublishStatus as PlatformStatus; use App\Enums\Post\Status as PostStatus; -use App\Enums\PostPlatform\Status as PlatformStatus; use App\Jobs\PublishPost; use App\Models\Post; use App\Models\PostPlatform; diff --git a/tests/Feature/Analytics/BackfillExistingAnalyticsCommandTest.php b/tests/Feature/Analytics/BackfillExistingAnalyticsCommandTest.php deleted file mode 100644 index 05cc199b44..0000000000 --- a/tests/Feature/Analytics/BackfillExistingAnalyticsCommandTest.php +++ /dev/null @@ -1,423 +0,0 @@ -create(); - $account = SocialAccount::factory()->instagram()->create(['workspace_id' => $workspace->id]); - $published = PostPlatform::factory()->instagram()->published()->create([ - 'social_account_id' => $account->id, - 'platform' => $account->platform, - ]); - $published->post->update(['workspace_id' => $workspace->id]); - $failed = PostPlatform::factory()->instagram()->failed()->create([ - 'social_account_id' => $account->id, - 'platform' => $account->platform, - ]); - - app()->call([new BackfillTryPostPublications([$published->id, $failed->id]), 'handle']); - - expect(AnalyticsPublication::query()->where('post_platform_id', $published->id)->exists())->toBeTrue() - ->and(AnalyticsPublication::query()->where('post_platform_id', $failed->id)->exists())->toBeFalse(); -}); - -test('rollout command dispatches bounded jobs only for eligible scoped accounts and destinations', function () { - Bus::fake(); - $workspace = Workspace::factory()->create(); - $eligible = SocialAccount::factory()->instagram()->create([ - 'workspace_id' => $workspace->id, - 'status' => Status::Connected, - ]); - SocialAccount::factory()->linkedin()->create([ - 'workspace_id' => $workspace->id, - 'status' => Status::Connected, - ]); - $second = SocialAccount::factory()->instagram()->create([ - 'workspace_id' => $workspace->id, - 'status' => Status::Connected, - ]); - $published = PostPlatform::factory()->instagram()->published()->create([ - 'social_account_id' => $eligible->id, - 'platform' => $eligible->platform, - ]); - $published->post->update(['workspace_id' => $workspace->id]); - PostPlatform::factory()->instagram()->create([ - 'social_account_id' => $eligible->id, - 'platform' => $eligible->platform, - 'status' => PostPlatformStatus::Failed, - ]); - Bus::fake(); - - expect(SocialAccount::query()->connected()->includedInAnalytics() - ->where('workspace_id', $workspace->id)->pluck('id')->all())->toEqualCanonicalizing([$eligible->id, $second->id]); - - $this->artisan('analytics:backfill-existing', ['--workspace' => $workspace->id, '--include-unsubscribed' => true]) - ->assertSuccessful(); - - Bus::assertDispatched(BootstrapAccountAnalytics::class, fn ($job): bool => $job->socialAccountId === $eligible->id); - Bus::assertDispatched(CollectAccountDailySnapshot::class, fn ($job): bool => $job->socialAccountId === $eligible->id && $job->observationDate === now('UTC')->toDateString()); - Bus::assertDispatched(BackfillTryPostPublications::class, fn ($job): bool => $job->postPlatformIds === [$published->id]); - Bus::assertDispatched(BootstrapAccountAnalytics::class, fn ($job): bool => $job->socialAccountId === $second->id); - Bus::assertDispatchedTimes(BootstrapAccountAnalytics::class, 2); - Bus::assertDispatchedTimes(BackfillTryPostPublications::class, 1); -}); - -test('rollout reports orphaned historical destinations without inventing an identity', function () { - Bus::fake(); - $workspace = Workspace::factory()->create(); - $destination = PostPlatform::factory()->instagram()->published()->create(); - $destination->post->update(['workspace_id' => $workspace->id]); - $destination->updateQuietly(['social_account_id' => null]); - Bus::fake(); - - $this->artisan('analytics:backfill-existing', ['--workspace' => $workspace->id, '--include-unsubscribed' => true]) - ->expectsOutputToContain('historical_identity_unrecoverable=1') - ->assertSuccessful(); - - Bus::assertNotDispatched(BackfillTryPostPublications::class); -}); - -test('deployment rollout follows Cashier subscription eligibility', function () { - Bus::fake(); - $eligibleAccounts = []; - $eligibleDestinations = []; - $workspaces = []; - - foreach (['active', 'trialing', 'past_due', 'grace_period', 'ended', 'none'] as $status) { - $workspace = Workspace::factory()->create(); - $workspaces[$status] = $workspace; - - if ($status !== 'none') { - $workspace->account->subscriptions()->create([ - 'type' => Account::SUBSCRIPTION_NAME, - 'stripe_id' => 'sub_'.fake()->uuid(), - 'stripe_status' => in_array($status, ['grace_period', 'ended'], true) ? 'active' : $status, - 'stripe_price' => 'price_123', - 'ends_at' => match ($status) { - 'grace_period' => now()->addDay(), - 'ended' => now()->subDay(), - default => null, - }, - ]); - } - - $socialAccount = SocialAccount::factory()->instagram()->create([ - 'workspace_id' => $workspace->id, - 'status' => Status::Connected, - ]); - $destination = PostPlatform::factory()->instagram()->published()->create([ - 'post_id' => Post::factory()->create(['workspace_id' => $workspace->id])->id, - 'social_account_id' => $socialAccount->id, - ]); - - if (in_array($status, ['active', 'grace_period', 'trialing', 'past_due'], true)) { - $orphan = PostPlatform::factory()->instagram()->published()->create([ - 'post_id' => Post::factory()->create(['workspace_id' => $workspace->id])->id, - 'social_account_id' => $socialAccount->id, - ]); - $orphan->updateQuietly(['social_account_id' => null]); - } - - if (in_array($status, ['active', 'grace_period', 'trialing', 'past_due'], true)) { - $eligibleAccounts[] = $socialAccount->id; - $eligibleDestinations[] = $destination->id; - } - } - - Bus::fake(); - $this->artisan('analytics:backfill-existing') - ->expectsOutputToContain('historical_identity_unrecoverable=4') - ->assertSuccessful(); - - expect(Bus::dispatched(BootstrapAccountAnalytics::class)->pluck('socialAccountId')->sort()->values()->all())->toBe(collect($eligibleAccounts)->sort()->values()->all()) - ->and(Bus::dispatched(CollectAccountDailySnapshot::class)->pluck('socialAccountId')->sort()->values()->all())->toBe(collect($eligibleAccounts)->sort()->values()->all()) - ->and(Bus::dispatched(BackfillTryPostPublications::class)->flatMap->postPlatformIds->sort()->values()->all())->toBe(collect($eligibleDestinations)->sort()->values()->all()); - - Bus::fake(); - $this->artisan('analytics:backfill-existing', [ - '--workspace' => $workspaces['trialing']->id, - ])->assertSuccessful(); - - Bus::assertDispatched(BootstrapAccountAnalytics::class); - Bus::assertDispatched(BackfillTryPostPublications::class); -}); - -test('deployment rollout uses the latest Cashier subscription of the account', function () { - Bus::fake(); - $workspace = Workspace::factory()->create(); - $workspace->account->subscriptions()->create([ - 'type' => Account::SUBSCRIPTION_NAME, - 'stripe_id' => 'sub_'.fake()->uuid(), - 'stripe_status' => 'active', - 'stripe_price' => 'price_123', - 'created_at' => now()->subDays(2), - ]); - $workspace->account->subscriptions()->create([ - 'type' => Account::SUBSCRIPTION_NAME, - 'stripe_id' => 'sub_'.fake()->uuid(), - 'stripe_status' => 'active', - 'stripe_price' => 'price_123', - 'ends_at' => now()->subDay(), - 'created_at' => now()->subDay(), - ]); - $socialAccount = SocialAccount::factory()->instagram()->create(['workspace_id' => $workspace->id]); - PostPlatform::factory()->instagram()->published()->create([ - 'post_id' => Post::factory()->create(['workspace_id' => $workspace->id])->id, - 'social_account_id' => $socialAccount->id, - ]); - - expect($workspace->account->fresh()->subscriptions->first()->ends_at?->isPast())->toBeTrue() - ->and($workspace->account->fresh()->subscribed(Account::SUBSCRIPTION_NAME))->toBeFalse(); - - Bus::fake(); - $this->artisan('analytics:backfill-existing', ['--workspace' => $workspace->id])->assertSuccessful(); - - Bus::assertNotDispatched(BootstrapAccountAnalytics::class); - Bus::assertNotDispatched(BackfillTryPostPublications::class); -}); - -test('account backfill visits every identity once across ID pages despite platform sorting', function () { - Bus::fake(); - $workspace = Workspace::factory()->create(); - SocialAccount::factory()->instagram()->count(100)->create([ - 'workspace_id' => $workspace->id, - 'status' => Status::Connected, - ]); - SocialAccount::factory()->facebook()->create([ - 'id' => 'ffffffff-ffff-4fff-8fff-ffffffffffff', - 'workspace_id' => $workspace->id, - 'status' => Status::Connected, - ]); - Bus::fake(); - - $this->artisan('analytics:backfill-existing', ['--workspace' => $workspace->id, '--include-unsubscribed' => true])->assertSuccessful(); - - $jobs = Bus::dispatched(BootstrapAccountAnalytics::class); - expect($jobs)->toHaveCount(101) - ->and($jobs->pluck('socialAccountId')->unique())->toHaveCount(101); -}); - -test('manual rollout rerun only redispatches missing publications and unfinished account bootstraps', function () { - Bus::fake(); - $workspace = Workspace::factory()->create(); - $account = SocialAccount::factory()->instagram()->create([ - 'workspace_id' => $workspace->id, - ]); - $destination = PostPlatform::factory()->instagram()->published()->create([ - 'post_id' => Post::factory()->create(['workspace_id' => $workspace->id])->id, - 'social_account_id' => $account->id, - ]); - Bus::fake(); - - $this->artisan('analytics:backfill-existing', [ - '--workspace' => $workspace->id, - '--include-unsubscribed' => true, - ])->assertSuccessful(); - - Bus::assertDispatched(CollectAccountDailySnapshot::class, fn ($job): bool => $job->socialAccountId === $account->id); - Bus::fake(); - $this->artisan('analytics:backfill-existing', [ - '--workspace' => $workspace->id, - '--include-unsubscribed' => true, - ])->assertSuccessful(); - - Bus::assertDispatched(BackfillTryPostPublications::class, fn ($job): bool => $job->postPlatformIds === [$destination->id]); - Bus::assertDispatched(BootstrapAccountAnalytics::class, fn ($job): bool => $job->socialAccountId === $account->id); - app()->call([new BackfillTryPostPublications([$destination->id]), 'handle']); - (new BootstrapAccountAnalytics($account->id))->handleFor($account->id); - Bus::fake(); - - $this->artisan('analytics:backfill-existing', [ - '--workspace' => $workspace->id, - '--include-unsubscribed' => true, - ])->assertSuccessful(); - - Bus::assertNotDispatched(BackfillTryPostPublications::class); - Bus::assertNotDispatched(BootstrapAccountAnalytics::class); - Bus::assertNotDispatched(CollectAccountDailySnapshot::class); -}); - -test('manual rollout rerun recovers a stale bootstrap without repeating a recent one', function () { - Bus::fake(); - $workspace = Workspace::factory()->create(); - $staleAccount = SocialAccount::factory()->instagram()->create(['workspace_id' => $workspace->id]); - $recentAccount = SocialAccount::factory()->instagram()->create(['workspace_id' => $workspace->id]); - $queueFailedAccount = SocialAccount::factory()->instagram()->create(['workspace_id' => $workspace->id]); - $permissionFailedAccount = SocialAccount::factory()->instagram()->create(['workspace_id' => $workspace->id]); - - foreach ([$staleAccount, $recentAccount, $queueFailedAccount, $permissionFailedAccount] as $account) { - AnalyticsSyncState::factory()->create([ - ...AnalyticsSyncState::identityFor($account), - 'social_account_id' => $account->id, - 'collector' => SyncCollector::PublicationBackfill, - 'status' => in_array($account->id, [$queueFailedAccount->id, $permissionFailedAccount->id], true) - ? SyncStatus::Failed - : SyncStatus::Pending, - 'last_error_category' => match ($account->id) { - $queueFailedAccount->id => 'queue_failed', - $permissionFailedAccount->id => 'permission', - default => null, - }, - 'updated_at' => $account->is($recentAccount) ? now() : now()->subHours(3), - ]); - } - - Bus::fake(); - $this->artisan('analytics:backfill-existing', [ - '--workspace' => $workspace->id, - '--include-unsubscribed' => true, - ])->assertSuccessful(); - - Bus::assertDispatched(BootstrapAccountAnalytics::class, fn ($job): bool => $job->socialAccountId === $staleAccount->id); - Bus::assertDispatched(BootstrapAccountAnalytics::class, fn ($job): bool => $job->socialAccountId === $queueFailedAccount->id); - Bus::assertDispatchedTimes(BootstrapAccountAnalytics::class, 2); - Bus::assertNotDispatched(CollectAccountDailySnapshot::class); -}); - -test('rollout bootstrap and local publication jobs retry transient failures', function () { - $bootstrap = new BootstrapAccountAnalytics(fake()->uuid()); - $publications = new BackfillTryPostPublications([fake()->uuid()]); - - expect($bootstrap->tries)->toBe(3) - ->and($bootstrap->backoff())->toBe([60, 300]) - ->and($publications->tries)->toBe(3) - ->and($publications->backoff())->toBe([60, 300]); -}); - -test('local backfill resolves one account identity per batch without repeated account reads', function () { - $workspace = Workspace::factory()->create(); - $account = SocialAccount::factory()->instagram()->create(['workspace_id' => $workspace->id]); - $destinations = collect(range(1, 3))->map(function () use ($account, $workspace): PostPlatform { - $destination = PostPlatform::factory()->instagram()->published()->create([ - 'social_account_id' => $account->id, - 'platform' => $account->platform, - ]); - $destination->post->update(['workspace_id' => $workspace->id]); - - return $destination; - }); - $accountKeys = Mockery::mock(ResolveAnalyticsAccountKey::class); - $accountKeys->shouldReceive('for')->once()->andReturn($account->id); - app()->instance(ResolveAnalyticsAccountKey::class, $accountKeys); - $accountReads = []; - DB::listen(function ($query) use (&$accountReads): void { - if (str_contains($query->sql, 'social_accounts')) { - $accountReads[] = $query->sql; - } - }); - - app()->call([new BackfillTryPostPublications($destinations->pluck('id')->all()), 'handle']); - - expect($accountReads)->toHaveCount(1) - ->and(AnalyticsPublication::query()->whereIn('post_platform_id', $destinations->pluck('id'))->count())->toBe(3); -}); - -test('the rollout can be limited to some platforms', function () { - Bus::fake(); - $workspace = Workspace::factory()->create(); - $x = SocialAccount::factory()->x()->create(['workspace_id' => $workspace->id, 'status' => Status::Connected]); - SocialAccount::factory()->instagram()->create(['workspace_id' => $workspace->id, 'status' => Status::Connected]); - Bus::fake(); - - $this->artisan('analytics:backfill-existing', ['--workspace' => $workspace->id, '--include-unsubscribed' => true, '--platforms' => 'x']) - ->expectsOutputToContain('accounts_dispatched=1') - ->assertSuccessful(); - - expect(Bus::dispatched(BootstrapAccountAnalytics::class)->pluck('socialAccountId')->all())->toBe([$x->id]); -}); - -test('an unknown platform stops the rollout before dispatching anything', function () { - Bus::fake(); - - $this->artisan('analytics:backfill-existing', ['--platforms' => 'instagram,myspace']) - ->expectsOutputToContain('myspace') - ->assertFailed(); - - Bus::assertNothingDispatched(); -}); - -test('the rollout spaces account batches by the configured delay', function () { - Bus::fake(); - $workspace = Workspace::factory()->create(); - SocialAccount::factory()->instagram()->count(3)->create(['workspace_id' => $workspace->id, 'status' => Status::Connected]); - Bus::fake(); - - $this->artisan('analytics:backfill-existing', ['--workspace' => $workspace->id, '--include-unsubscribed' => true, '--chunk' => 2, '--delay' => 300]) - ->expectsOutputToContain('accounts_dispatched=3') - ->assertSuccessful(); - - expect(Bus::dispatched(BootstrapAccountAnalytics::class)->map(fn ($job): int => (int) $job->delay)->sort()->values()->all()) - ->toBe([0, 0, 300]); -}); - -test('the rollout reports links queued and posts already imported', function () { - Bus::fake(); - $workspace = Workspace::factory()->create(); - $account = SocialAccount::factory()->instagram()->create(['workspace_id' => $workspace->id, 'status' => Status::Connected]); - $published = PostPlatform::factory()->instagram()->published()->create(['social_account_id' => $account->id, 'platform' => $account->platform]); - $published->post->update(['workspace_id' => $workspace->id]); - Post::factory()->imported()->count(2)->create(['workspace_id' => $workspace->id]); - Bus::fake(); - - $this->artisan('analytics:backfill-existing', ['--workspace' => $workspace->id, '--include-unsubscribed' => true]) - ->expectsOutputToContain('publications_to_link=1') - ->expectsOutputToContain('posts_imported=2') - ->assertSuccessful(); -}); - -test('a trypost post linked by the rollout is not imported a second time', function () { - Bus::fake(); - $workspace = Workspace::factory()->create(); - $account = SocialAccount::factory()->instagram()->create(['workspace_id' => $workspace->id]); - $published = PostPlatform::factory()->instagram()->published()->create([ - 'social_account_id' => $account->id, - 'platform' => $account->platform, - 'platform_post_id' => 'ours-1', - ]); - $published->post->update(['workspace_id' => $workspace->id]); - - app()->call([new BackfillTryPostPublications([$published->id]), 'handle']); - AnalyticsPublication::query()->where('post_platform_id', $published->id)->update(['social_account_id' => $account->id]); - ImportExternalPosts::execute($account); - - expect(Post::query()->imported()->count())->toBe(0) - ->and(AnalyticsPublication::query()->where('post_platform_id', $published->id)->count())->toBe(1); -}); - -test('the rollout counts imported posts one workspace page at a time', function () { - Bus::fake(); - $workspaces = Workspace::factory()->count(101)->create(); - Post::factory()->imported()->create(['workspace_id' => $workspaces->first()->id]); - Post::factory()->imported()->create(['workspace_id' => $workspaces->last()->id]); - $workspaceIds = $workspaces->modelKeys(); - $largestWorkspaceBinding = 0; - DB::listen(function ($query) use ($workspaceIds, &$largestWorkspaceBinding): void { - $largestWorkspaceBinding = max($largestWorkspaceBinding, count(array_intersect($query->bindings, $workspaceIds))); - }); - - $this->artisan('analytics:backfill-existing', ['--include-unsubscribed' => true]) - ->expectsOutputToContain('posts_imported=2') - ->assertSuccessful(); - - expect($largestWorkspaceBinding)->toBeLessThanOrEqual(100); -}); diff --git a/tests/Feature/Analytics/PublicationReconciliationTest.php b/tests/Feature/Analytics/PublicationReconciliationTest.php index 58c97326e4..2c44661b4c 100644 --- a/tests/Feature/Analytics/PublicationReconciliationTest.php +++ b/tests/Feature/Analytics/PublicationReconciliationTest.php @@ -8,8 +8,8 @@ use App\Dto\Analytics\DiscoveredPublication; use App\Enums\Analytics\PublicationContentType; use App\Enums\Analytics\PublicationOrigin; +use App\Enums\Post\PublishStatus as Status; use App\Enums\PostPlatform\ContentType; -use App\Enums\PostPlatform\Status; use App\Enums\SocialAccount\Platform; use App\Events\PostDeleted; use App\Jobs\Analytics\SyncTryPostPublication as SyncTryPostPublicationJob; diff --git a/tests/Feature/Api/RepurposeApiTest.php b/tests/Feature/Api/RepurposeApiTest.php index 29023c6b1a..096f82b29f 100644 --- a/tests/Feature/Api/RepurposeApiTest.php +++ b/tests/Feature/Api/RepurposeApiTest.php @@ -2,8 +2,8 @@ declare(strict_types=1); +use App\Enums\Post\PublishStatus as PostPlatformStatus; use App\Enums\PostPlatform\ContentType; -use App\Enums\PostPlatform\Status as PostPlatformStatus; use App\Enums\Repurpose\ItemStatus; use App\Enums\Repurpose\PauseReason; use App\Enums\Repurpose\PublishMode; diff --git a/tests/Feature/AuditLegacyPostsTest.php b/tests/Feature/AuditLegacyPostsTest.php deleted file mode 100644 index 216c704165..0000000000 --- a/tests/Feature/AuditLegacyPostsTest.php +++ /dev/null @@ -1,39 +0,0 @@ -create(); - $workspace = Workspace::factory()->create(['user_id' => $user->id]); - Post::factory()->create([ - 'workspace_id' => $workspace->id, - 'user_id' => $user->id, - 'status' => Status::Scheduled, - 'scheduled_at' => now()->addDay(), - ]); - - $this->artisan('posts:audit-legacy', ['--strict' => true])->assertFailed(); -}); - -test('audit is read only and reports editable multi-target posts for splitting', function () { - $user = User::factory()->create(); - $workspace = Workspace::factory()->create(['user_id' => $user->id]); - $post = Post::factory()->create(['workspace_id' => $workspace->id, 'user_id' => $user->id]); - $accounts = SocialAccount::factory()->count(2)->create(['workspace_id' => $workspace->id]); - foreach ($accounts as $account) { - PostPlatform::factory()->create(['post_id' => $post->id, 'social_account_id' => $account->id]); - } - - $this->artisan('posts:audit-legacy')->assertSuccessful(); - $this->artisan('posts:audit-legacy', ['--strict' => true])->assertFailed(); - - expect(Post::count())->toBe(1) - ->and(PostPlatform::count())->toBe(2); -}); diff --git a/tests/Feature/BakeAspectRatioCropsTest.php b/tests/Feature/BakeAspectRatioCropsTest.php deleted file mode 100644 index cf437a0769..0000000000 --- a/tests/Feature/BakeAspectRatioCropsTest.php +++ /dev/null @@ -1,304 +0,0 @@ -migration = require database_path('migrations/2026_10_04_202400_remove_aspect_ratio_from_post_platforms_meta.php'); - $this->user = User::factory()->create(); - $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); - $this->instagram = SocialAccount::factory()->instagram()->create([ - 'workspace_id' => $this->workspace->id, - 'platform_user_id' => 'ig_bake', - 'token_expires_at' => now()->addDays(60), - ]); - $this->facebook = SocialAccount::factory()->facebook()->create([ - 'workspace_id' => $this->workspace->id, - 'platform_user_id' => 'page_bake', - 'token_expires_at' => null, - 'meta' => ['page_id' => 'page_bake'], - ]); -}); - -function bakeJpeg(int $width, int $height): string -{ - $image = imagecreatetruecolor($width, $height); - ob_start(); - imagejpeg($image); - $bytes = (string) ob_get_clean(); - imagedestroy($image); - - return $bytes; -} - -/** - * @param list $files - * @param array $meta - * @param array $postAttributes - * @return array{post: Post, target: PostPlatform, media: list} - */ -function bakeLegacyPost(Workspace $workspace, SocialAccount $account, ContentType $contentType, array $files, array $meta, PostStatus $status = PostStatus::Scheduled, PlatformStatus $targetStatus = PlatformStatus::Pending, array $postAttributes = []): array -{ - $post = Post::factory()->scheduled()->create([ - 'workspace_id' => $workspace->id, - 'user_id' => $workspace->user_id, - 'status' => $status, - 'content' => 'Legacy caption', - ...$postAttributes, - ]); - $media = []; - - foreach ($files as $index => $file) { - if ($file === 'video') { - $row = Media::factory()->video()->stored()->ownedByPost($post)->create(['order' => $index]); - } else { - $row = Media::factory()->ownedByPost($post)->create([ - 'path' => 'medias/'.Str::uuid().'.jpg', - 'order' => $index, - 'meta' => ['width' => $file[0], 'height' => $file[1]], - ]); - Storage::put($row->path, bakeJpeg($file[0], $file[1])); - } - - $media[] = $row; - } - - $items = array_map(fn (Media $row): array => MediaItem::fromMedia($row, $row->isImage() ? "alt {$row->id}" : null)->toArray(), $media); - Post::query()->whereKey($post->id)->update(['media' => json_encode($items)]); - - $target = PostPlatform::factory()->create([ - 'post_id' => $post->id, - 'social_account_id' => $account->id, - 'platform' => $account->platform, - 'content_type' => $contentType, - 'status' => $targetStatus, - 'meta' => $meta, - 'enabled' => true, - ]); - - return ['post' => $post->fresh(), 'target' => $target, 'media' => $media]; -} - -/** - * @return array{width: int, height: int} - */ -function bakedDimensions(array $item): array -{ - return ImageDimensions::fromBytes((string) Storage::get(data_get($item, 'path'))); -} - -function runBakeRelease(object $migration): void -{ - $migration->up(); - test()->artisan('posts:bake-aspect-ratio-crops')->assertSuccessful(); -} - -test('a scheduled instagram feed 9:16 photo with 4:5 selected is cropped to 4:5 and publishes that image', function () { - ['post' => $post, 'target' => $target, 'media' => $media] = bakeLegacyPost($this->workspace, $this->instagram, ContentType::InstagramFeed, [[1080, 1920]], ['aspect_ratio' => '4:5', 'is_ai_generated' => true]); - - runBakeRelease($this->migration); - - $post->refresh(); - $item = data_get($post->media, 0); - $row = Media::query()->findOrFail(data_get($item, 'id')); - - expect(bakedDimensions($item))->toBe(['width' => 1080, 'height' => 1350]) - ->and($row->post_id)->toBe($post->id) - ->and($row->collection)->toBe(Media::COLLECTION_MEDIA) - ->and($row->upload_token)->toBeNull() - ->and(data_get($row->meta, 'width'))->toBe(1080) - ->and(data_get($row->meta, 'height'))->toBe(1350) - ->and(data_get($item, 'meta.alt_text'))->toBe("alt {$media[0]->id}") - ->and(Media::query()->whereKey($media[0]->id)->exists())->toBeFalse() - ->and($target->fresh()->meta)->toEqual(['is_ai_generated' => true]); - - $graph = (string) config('trypost.platforms.instagram.graph_api'); - Http::fake(fn (Request $request) => match (true) { - str_ends_with($request->url(), '/ig_bake/media') => Http::response(['id' => 'container-1']), - str_ends_with($request->url(), '/ig_bake/media_publish') => Http::response(['id' => 'ig-media-1']), - str_contains($request->url(), '/container-1') => Http::response(['status_code' => 'FINISHED']), - str_contains($request->url(), '/ig-media-1') => Http::response(['permalink' => 'https://www.instagram.com/p/baked/']), - default => Http::response([], 404), - }); - - (new PublishToSocialPlatform($target->fresh()))->handle(); - - expect($target->fresh()->status)->toBe(PlatformStatus::Published); - Http::assertSent(fn (Request $request): bool => $request->url() === "{$graph}/ig_bake/media" - && data_get($request->data(), 'image_url') === Storage::url($row->path)); -}); - -test('an instagram carousel crops every image and keeps its video', function () { - ['post' => $post, 'media' => $media] = bakeLegacyPost($this->workspace, $this->instagram, ContentType::InstagramFeed, [[1080, 1920], 'video', [1600, 900]], ['aspect_ratio' => '1:1']); - - runBakeRelease($this->migration); - - $items = $post->fresh()->media; - - expect($items)->toHaveCount(3) - ->and(bakedDimensions($items[0]))->toBe(['width' => 1080, 'height' => 1080]) - ->and(data_get($items, '1.id'))->toBe($media[1]->id) - ->and(bakedDimensions($items[2]))->toBe(['width' => 900, 'height' => 900]) - ->and(Media::query()->where('post_id', $post->id)->orderBy('order')->pluck('id')->all())->toBe(array_column($items, 'id')); -}); - -test('a facebook post crops its images, and one led by a video keeps them', function () { - ['post' => $images] = bakeLegacyPost($this->workspace, $this->facebook, ContentType::FacebookPost, [[1000, 1000], [800, 1000]], ['aspect_ratio' => '16:9']); - ['post' => $video, 'media' => $videoMedia] = bakeLegacyPost($this->workspace, $this->facebook, ContentType::FacebookPost, ['video', [1000, 1000]], ['aspect_ratio' => '16:9']); - - runBakeRelease($this->migration); - - $items = $images->fresh()->media; - - expect(bakedDimensions($items[0]))->toBe(['width' => 1000, 'height' => 563]) - ->and(bakedDimensions($items[1]))->toBe(['width' => 800, 'height' => 450]) - ->and(array_column($video->fresh()->media, 'id'))->toBe([$videoMedia[0]->id, $videoMedia[1]->id]); - - Http::fake(['*/page_bake/photos' => Http::response(['id' => 'photo-1']), '*/page_bake/feed' => Http::response(['id' => 'page_bake_1'])]); - - $target = $images->postPlatforms()->sole(); - (new PublishToSocialPlatform($target))->handle(); - - expect($target->fresh()->status)->toBe(PlatformStatus::Published); - Http::assertSent(fn (Request $request): bool => str_ends_with($request->url(), '/page_bake/photos') - && data_get($request->data(), 'url') === Storage::url(data_get($items, '0.path'))); -}); - -test('targets the publishers never cropped keep their media and lose the key', function () { - ['post' => $story, 'target' => $storyTarget, 'media' => $storyMedia] = bakeLegacyPost($this->workspace, $this->instagram, ContentType::InstagramStory, [[1080, 1920]], ['aspect_ratio' => '4:5']); - ['post' => $published, 'target' => $publishedTarget, 'media' => $publishedMedia] = bakeLegacyPost($this->workspace, $this->instagram, ContentType::InstagramFeed, [[1080, 1920]], ['aspect_ratio' => '4:5'], PostStatus::Published, PlatformStatus::Published); - ['post' => $original, 'target' => $originalTarget, 'media' => $originalMedia] = bakeLegacyPost($this->workspace, $this->instagram, ContentType::InstagramFeed, [[1080, 1350]], ['aspect_ratio' => 'original']); - ['post' => $atRatio, 'target' => $atRatioTarget, 'media' => $atRatioMedia] = bakeLegacyPost($this->workspace, $this->instagram, ContentType::InstagramFeed, [[1080, 1350]], ['aspect_ratio' => '4:5']); - ['post' => $started, 'target' => $startedTarget, 'media' => $startedMedia] = bakeLegacyPost($this->workspace, $this->instagram, ContentType::InstagramFeed, [[1080, 1920]], ['aspect_ratio' => '4:5'], PostStatus::Publishing, PlatformStatus::Publishing); - $startedTarget->update(['error_context' => ['instagram_workflow' => ['stage' => 'final_container', 'container_id' => 'container-9']]]); - - runBakeRelease($this->migration); - - foreach ([[$story, $storyTarget, $storyMedia], [$published, $publishedTarget, $publishedMedia], [$original, $originalTarget, $originalMedia], [$atRatio, $atRatioTarget, $atRatioMedia], [$started, $startedTarget, $startedMedia]] as [$post, $target, $media]) { - expect(data_get($post->fresh()->media, '0.id'))->toBe($media[0]->id) - ->and($target->fresh()->meta)->toEqual([]); - } -}); - -test('a target that publishes without user action is baked', function (PostStatus $status, PlatformStatus $targetStatus, array $postAttributes) { - ['post' => $post, 'target' => $target, 'media' => $media] = bakeLegacyPost($this->workspace, $this->instagram, ContentType::InstagramFeed, [[1080, 1920]], ['aspect_ratio' => '4:5'], $status, $targetStatus, $postAttributes); - - runBakeRelease($this->migration); - - expect(data_get($post->fresh()->media, '0.id'))->not->toBe($media[0]->id) - ->and(bakedDimensions(data_get($post->fresh()->media, 0)))->toBe(['width' => 1080, 'height' => 1350]) - ->and($target->fresh()->meta)->toEqual([]); -})->with([ - 'scheduled' => [PostStatus::Scheduled, PlatformStatus::Pending, []], - 'queued' => [PostStatus::Scheduled, PlatformStatus::Pending, ['schedule_mode' => ScheduleMode::Queue]], - 'pending approval' => [PostStatus::PendingApproval, PlatformStatus::Pending, []], - 'publishing' => [PostStatus::Publishing, PlatformStatus::Publishing, []], - 'retrying' => [PostStatus::Publishing, PlatformStatus::Retrying, []], -]); - -test('a draft or a failed target keeps its original image and loses the key', function (PostStatus $status, PlatformStatus $targetStatus) { - ['post' => $post, 'target' => $target, 'media' => $media] = bakeLegacyPost($this->workspace, $this->instagram, ContentType::InstagramFeed, [[1080, 1920]], ['aspect_ratio' => '4:5', 'is_ai_generated' => true], $status, $targetStatus); - - expect(BakeLegacyAspectRatioCrops::pending())->toBe(0); - - runBakeRelease($this->migration); - - $item = data_get($post->fresh()->media, 0); - - expect(data_get($item, 'id'))->toBe($media[0]->id) - ->and(bakedDimensions($item))->toBe(['width' => 1080, 'height' => 1920]) - ->and($target->fresh()->meta)->toEqual(['is_ai_generated' => true]); -})->with([ - 'draft' => [PostStatus::Draft, PlatformStatus::Pending], - 'failed post' => [PostStatus::Failed, PlatformStatus::Failed], - 'failed target of a post still publishing' => [PostStatus::Publishing, PlatformStatus::Failed], -]); - -test('an instagram target connected through facebook is baked like a direct one', function () { - $account = SocialAccount::factory()->instagram()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::InstagramFacebook, - ]); - ['post' => $post] = bakeLegacyPost($this->workspace, $account, ContentType::InstagramFeed, [[1080, 1920], [1920, 1080]], ['aspect_ratio' => '1:1']); - - runBakeRelease($this->migration); - - $items = $post->fresh()->media; - - expect(bakedDimensions($items[0]))->toBe(['width' => 1080, 'height' => 1080]) - ->and(bakedDimensions($items[1]))->toBe(['width' => 1080, 'height' => 1080]); -}); - -test('a recurring series is baked and its next occurrence carries the cropped image', function () { - ['post' => $post] = bakeLegacyPost($this->workspace, $this->instagram, ContentType::InstagramFeed, [[1080, 1920]], ['aspect_ratio' => '4:5'], postAttributes: [ - 'recurrence_frequency' => RecurrenceFrequency::Week, - 'recurrence_interval' => 1, - 'recurrence_remaining' => 3, - ]); - - runBakeRelease($this->migration); - - $next = ScheduleNextOccurrence::execute($post->fresh())->sole(); - - expect(bakedDimensions(data_get($post->fresh()->media, 0)))->toBe(['width' => 1080, 'height' => 1350]) - ->and(bakedDimensions(data_get($next->media, 0)))->toBe(['width' => 1080, 'height' => 1350]) - ->and($next->postPlatforms()->sole()->meta)->toEqual([]); -}); - -test('a post whose media is shared with another enabled target is skipped and reported', function () { - ['post' => $post, 'target' => $target, 'media' => $media] = bakeLegacyPost($this->workspace, $this->instagram, ContentType::InstagramFeed, [[1080, 1920]], ['aspect_ratio' => '4:5'], PostStatus::Publishing, PlatformStatus::Pending); - PostPlatform::factory()->create(['post_id' => $post->id, 'enabled' => true]); - - $this->migration->up(); - $this->artisan('posts:bake-aspect-ratio-crops') - ->expectsOutputToContain("their media is shared; they keep their aspect ratio until a run after posts:split-legacy-active): {$post->id}") - ->assertSuccessful(); - - expect(data_get($post->fresh()->media, '0.id'))->toBe($media[0]->id) - ->and($target->fresh()->meta)->toEqual(['aspect_ratio' => '4:5']); -}); - -test('a second run changes nothing', function () { - ['post' => $post] = bakeLegacyPost($this->workspace, $this->instagram, ContentType::InstagramFeed, [[1080, 1920], [1920, 1080]], ['aspect_ratio' => '4:5']); - - runBakeRelease($this->migration); - $media = $post->fresh()->media; - $files = Storage::allFiles(); - $rows = Media::query()->pluck('id')->sort()->values()->all(); - - $this->artisan('posts:bake-aspect-ratio-crops') - ->expectsOutputToContain('Baked 0 cropped image(s) into 0 post(s)') - ->assertSuccessful(); - - expect($post->fresh()->media)->toBe($media) - ->and(Storage::allFiles())->toBe($files) - ->and(Media::query()->pluck('id')->sort()->values()->all())->toBe($rows) - ->and(bakedDimensions($media[1]))->toBe(['width' => 864, 'height' => 1080]); -}); diff --git a/tests/Feature/Channels/ChannelGridTest.php b/tests/Feature/Channels/ChannelGridTest.php index b0054ec611..d00a000ead 100644 --- a/tests/Feature/Channels/ChannelGridTest.php +++ b/tests/Feature/Channels/ChannelGridTest.php @@ -2,8 +2,8 @@ declare(strict_types=1); +use App\Enums\Post\PublishStatus as Status; use App\Enums\PostPlatform\ContentType; -use App\Enums\PostPlatform\Status; use App\Enums\SocialAccount\Platform; use App\Models\Post; use App\Models\PostPlatform; diff --git a/tests/Feature/Channels/DisconnectChannelPostsTest.php b/tests/Feature/Channels/DisconnectChannelPostsTest.php index deb8024e3f..b42b21cd43 100644 --- a/tests/Feature/Channels/DisconnectChannelPostsTest.php +++ b/tests/Feature/Channels/DisconnectChannelPostsTest.php @@ -6,8 +6,8 @@ use App\Actions\Post\ImportExternalPosts; use App\Dto\Analytics\DiscoveredPublication; use App\Enums\Analytics\PublicationContentType; +use App\Enums\Post\PublishStatus as PlatformStatus; use App\Enums\Post\Status; -use App\Enums\PostPlatform\Status as PlatformStatus; use App\Events\PostDeleted; use App\Jobs\Analytics\BootstrapAccountAnalytics; use App\Jobs\Analytics\CollectAccountDailySnapshot; diff --git a/tests/Feature/Commands/CheckUpcomingPostConnectionsTest.php b/tests/Feature/Commands/CheckUpcomingPostConnectionsTest.php index 4727e2b843..718a8f03d3 100644 --- a/tests/Feature/Commands/CheckUpcomingPostConnectionsTest.php +++ b/tests/Feature/Commands/CheckUpcomingPostConnectionsTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use App\Enums\PostPlatform\Status as PostPlatformStatus; +use App\Enums\Post\PublishStatus as PostPlatformStatus; use App\Jobs\VerifyUpcomingPostConnections; use App\Models\Post; use App\Models\PostPlatform; diff --git a/tests/Feature/Commands/ReconcileGoogleBusinessPostsTest.php b/tests/Feature/Commands/ReconcileGoogleBusinessPostsTest.php index 7ea1afb322..76efa6e789 100644 --- a/tests/Feature/Commands/ReconcileGoogleBusinessPostsTest.php +++ b/tests/Feature/Commands/ReconcileGoogleBusinessPostsTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use App\Enums\PostPlatform\Status as PlatformStatus; +use App\Enums\Post\PublishStatus as PlatformStatus; use App\Jobs\ReconcileGoogleBusinessPost; use App\Models\Post; use App\Models\PostPlatform; diff --git a/tests/Feature/Commands/RecoverStuckPostsTest.php b/tests/Feature/Commands/RecoverStuckPostsTest.php index ea4fc6c9c2..4abcc5d68d 100644 --- a/tests/Feature/Commands/RecoverStuckPostsTest.php +++ b/tests/Feature/Commands/RecoverStuckPostsTest.php @@ -2,8 +2,8 @@ declare(strict_types=1); +use App\Enums\Post\PublishStatus as PlatformStatus; use App\Enums\Post\Status as PostStatus; -use App\Enums\PostPlatform\Status as PlatformStatus; use App\Enums\SocialAccount\Platform; use App\Events\PostPlatformStatusUpdated; use App\Jobs\PublishToSocialPlatform; diff --git a/tests/Feature/Commands/RetryFailedPostTest.php b/tests/Feature/Commands/RetryFailedPostTest.php index 38461ca883..3154fd45ad 100644 --- a/tests/Feature/Commands/RetryFailedPostTest.php +++ b/tests/Feature/Commands/RetryFailedPostTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); +use App\Enums\Post\PublishStatus as PlatformStatus; use App\Enums\Post\Status as PostStatus; use App\Enums\PostPlatform\ContentType; -use App\Enums\PostPlatform\Status as PlatformStatus; use App\Exceptions\Social\ErrorCategory; use App\Jobs\Analytics\BootstrapAccountAnalytics; use App\Jobs\Analytics\CollectAccountDailySnapshot; diff --git a/tests/Feature/Jobs/PostHog/SyncAccountPublishingActivityTest.php b/tests/Feature/Jobs/PostHog/SyncAccountPublishingActivityTest.php index 93406eed32..ee65585069 100644 --- a/tests/Feature/Jobs/PostHog/SyncAccountPublishingActivityTest.php +++ b/tests/Feature/Jobs/PostHog/SyncAccountPublishingActivityTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use App\Enums\PostPlatform\Status; +use App\Enums\Post\PublishStatus as Status; use App\Enums\SocialAccount\Platform; use App\Jobs\PostHog\SyncAccountPublishingActivity; use App\Models\Account; diff --git a/tests/Feature/Jobs/PublishLegacyTargetMediaTest.php b/tests/Feature/Jobs/PublishLegacyTargetMediaTest.php index ce718134a6..c544caeb3a 100644 --- a/tests/Feature/Jobs/PublishLegacyTargetMediaTest.php +++ b/tests/Feature/Jobs/PublishLegacyTargetMediaTest.php @@ -3,9 +3,9 @@ declare(strict_types=1); use App\Dto\MediaItem; +use App\Enums\Post\PublishStatus as PlatformStatus; use App\Enums\Post\Status as PostStatus; use App\Enums\PostPlatform\ContentType; -use App\Enums\PostPlatform\Status as PlatformStatus; use App\Jobs\PublishToSocialPlatform; use App\Models\Media; use App\Models\Post; diff --git a/tests/Feature/Jobs/PublishLimitRetryTest.php b/tests/Feature/Jobs/PublishLimitRetryTest.php index a4053aa211..d5174054e1 100644 --- a/tests/Feature/Jobs/PublishLimitRetryTest.php +++ b/tests/Feature/Jobs/PublishLimitRetryTest.php @@ -3,9 +3,9 @@ declare(strict_types=1); use App\Enums\Notification\Type; +use App\Enums\Post\PublishStatus as PlatformStatus; use App\Enums\Post\Status as PostStatus; use App\Enums\PostPlatform\ContentType; -use App\Enums\PostPlatform\Status as PlatformStatus; use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\BlueskyPublishException; use App\Exceptions\Social\DiscordPublishException; diff --git a/tests/Feature/Jobs/PublishToSocialPlatformMediaRecheckTest.php b/tests/Feature/Jobs/PublishToSocialPlatformMediaRecheckTest.php index 4d040fc4ca..66275d34f2 100644 --- a/tests/Feature/Jobs/PublishToSocialPlatformMediaRecheckTest.php +++ b/tests/Feature/Jobs/PublishToSocialPlatformMediaRecheckTest.php @@ -3,9 +3,9 @@ declare(strict_types=1); use App\Dto\MediaItem; +use App\Enums\Post\PublishStatus as PlatformStatus; use App\Enums\Post\Status as PostStatus; use App\Enums\PostPlatform\ContentType; -use App\Enums\PostPlatform\Status as PlatformStatus; use App\Enums\SocialAccount\Platform; use App\Jobs\PublishToSocialPlatform; use App\Models\Media; diff --git a/tests/Feature/Jobs/PublishToSocialPlatformTest.php b/tests/Feature/Jobs/PublishToSocialPlatformTest.php index b0dec1b131..cdd1ba0d87 100644 --- a/tests/Feature/Jobs/PublishToSocialPlatformTest.php +++ b/tests/Feature/Jobs/PublishToSocialPlatformTest.php @@ -5,9 +5,9 @@ use App\Enums\GoogleBusiness\LocalPostState; use App\Enums\GoogleBusiness\TopicType; use App\Enums\Notification\Type; +use App\Enums\Post\PublishStatus as PlatformStatus; use App\Enums\Post\Status as PostStatus; use App\Enums\PostPlatform\ContentType; -use App\Enums\PostPlatform\Status as PlatformStatus; use App\Enums\SocialAccount\Platform; use App\Enums\SocialAccount\Status as AccountStatus; use App\Enums\TikTok\PrivacyLevel; diff --git a/tests/Feature/Jobs/ReconcileGoogleBusinessPostTest.php b/tests/Feature/Jobs/ReconcileGoogleBusinessPostTest.php index 805256d447..67e0f7e53a 100644 --- a/tests/Feature/Jobs/ReconcileGoogleBusinessPostTest.php +++ b/tests/Feature/Jobs/ReconcileGoogleBusinessPostTest.php @@ -2,8 +2,8 @@ declare(strict_types=1); +use App\Enums\Post\PublishStatus as PlatformStatus; use App\Enums\Post\Status as PostStatus; -use App\Enums\PostPlatform\Status as PlatformStatus; use App\Enums\SocialAccount\Status as AccountStatus; use App\Jobs\PostHog\SyncAccountPublishingActivity; use App\Jobs\ReconcileGoogleBusinessPost; diff --git a/tests/Feature/Jobs/SocialPublishingPipelineTest.php b/tests/Feature/Jobs/SocialPublishingPipelineTest.php index eae68e05c8..f856095ad8 100644 --- a/tests/Feature/Jobs/SocialPublishingPipelineTest.php +++ b/tests/Feature/Jobs/SocialPublishingPipelineTest.php @@ -4,9 +4,9 @@ use App\Actions\Analytics\UpsertAnalyticsPublication; use App\Actions\Post\ImportExternalPosts; +use App\Enums\Post\PublishStatus as PlatformStatus; use App\Enums\Post\Status as PostStatus; use App\Enums\PostPlatform\ContentType; -use App\Enums\PostPlatform\Status as PlatformStatus; use App\Enums\SocialAccount\Platform; use App\Enums\SocialAccount\Status as AccountStatus; use App\Enums\TikTok\PrivacyLevel; diff --git a/tests/Feature/Jobs/VerifyUpcomingPostConnectionsTest.php b/tests/Feature/Jobs/VerifyUpcomingPostConnectionsTest.php index 9d5eb9b123..fc1fa27c49 100644 --- a/tests/Feature/Jobs/VerifyUpcomingPostConnectionsTest.php +++ b/tests/Feature/Jobs/VerifyUpcomingPostConnectionsTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use App\Enums\PostPlatform\Status as PostPlatformStatus; +use App\Enums\Post\PublishStatus as PostPlatformStatus; use App\Enums\SocialAccount\Status as SocialAccountStatus; use App\Exceptions\PlatformUnavailableException; use App\Exceptions\TokenExpiredException; diff --git a/tests/Feature/Mail/PostPublicationPreviewTest.php b/tests/Feature/Mail/PostPublicationPreviewTest.php index e6b447be99..017bafbbb5 100644 --- a/tests/Feature/Mail/PostPublicationPreviewTest.php +++ b/tests/Feature/Mail/PostPublicationPreviewTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use App\Enums\PostPlatform\Status; +use App\Enums\Post\PublishStatus as Status; use App\Enums\SocialAccount\Platform; use App\Enums\User\Locale; use App\Mail\PostPublished; diff --git a/tests/Feature/Mcp/RepurposeToolTest.php b/tests/Feature/Mcp/RepurposeToolTest.php index c6c29d0090..6ac9d1a83d 100644 --- a/tests/Feature/Mcp/RepurposeToolTest.php +++ b/tests/Feature/Mcp/RepurposeToolTest.php @@ -2,8 +2,8 @@ declare(strict_types=1); +use App\Enums\Post\PublishStatus as PostPlatformStatus; use App\Enums\PostPlatform\ContentType; -use App\Enums\PostPlatform\Status as PostPlatformStatus; use App\Enums\Repurpose\ItemStatus; use App\Enums\Repurpose\PauseReason; use App\Enums\Repurpose\PublishMode; diff --git a/tests/Feature/Migrations/RemoveAspectRatioMetaMigrationTest.php b/tests/Feature/Migrations/RemoveAspectRatioMetaMigrationTest.php index ae9eaa319b..131676cc41 100644 --- a/tests/Feature/Migrations/RemoveAspectRatioMetaMigrationTest.php +++ b/tests/Feature/Migrations/RemoveAspectRatioMetaMigrationTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); +use App\Enums\Post\PublishStatus as PlatformStatus; use App\Enums\Post\Status as PostStatus; use App\Enums\PostPlatform\ContentType; -use App\Enums\PostPlatform\Status as PlatformStatus; use App\Enums\SocialAccount\Platform; use App\Models\Post; use App\Models\PostPlatform; diff --git a/tests/Feature/Observers/PostPlatformObserverTest.php b/tests/Feature/Observers/PostPlatformObserverTest.php index 909db86d27..e16ca7273d 100644 --- a/tests/Feature/Observers/PostPlatformObserverTest.php +++ b/tests/Feature/Observers/PostPlatformObserverTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use App\Enums\PostPlatform\Status; +use App\Enums\Post\PublishStatus as Status; use App\Jobs\Analytics\SyncTryPostPublication; use App\Jobs\PostHog\SyncAccountPublishingActivity; use App\Models\Account; diff --git a/tests/Feature/Parity/FullPostParityTest.php b/tests/Feature/Parity/FullPostParityTest.php index aed5fc760b..5a20f5526b 100644 --- a/tests/Feature/Parity/FullPostParityTest.php +++ b/tests/Feature/Parity/FullPostParityTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); +use App\Enums\Post\PublishStatus as PostPlatformStatus; use App\Enums\Post\ScheduleMode; use App\Enums\Post\Status; -use App\Enums\PostPlatform\Status as PostPlatformStatus; use App\Mcp\Servers\TryPostServer; use App\Mcp\Tools\Post\CreatePostsTool; use App\Mcp\Tools\Post\CreatePostTool; diff --git a/tests/Feature/PostControllerTest.php b/tests/Feature/PostControllerTest.php index 5eadabf344..6a8c2e97f1 100644 --- a/tests/Feature/PostControllerTest.php +++ b/tests/Feature/PostControllerTest.php @@ -4,10 +4,10 @@ use App\Enums\Analytics\PublicationContentType; use App\Enums\Post\CreatedVia; +use App\Enums\Post\PublishStatus as Status; use App\Enums\Post\ScheduleMode; use App\Enums\Post\Status as PostStatus; use App\Enums\PostPlatform\ContentType; -use App\Enums\PostPlatform\Status; use App\Enums\SocialAccount\Platform; use App\Jobs\Analytics\BootstrapAccountAnalytics; use App\Jobs\Analytics\CollectAccountDailySnapshot; diff --git a/tests/Feature/Posts/ImportExternalPostsTest.php b/tests/Feature/Posts/ImportExternalPostsTest.php index d1b0dc7037..12094224c8 100644 --- a/tests/Feature/Posts/ImportExternalPostsTest.php +++ b/tests/Feature/Posts/ImportExternalPostsTest.php @@ -7,9 +7,9 @@ use App\Enums\Analytics\PublicationContentType; use App\Enums\Analytics\PublicationOrigin; use App\Enums\Post\Origin; +use App\Enums\Post\PublishStatus as PostPlatformStatus; use App\Enums\Post\Status as PostStatus; use App\Enums\PostPlatform\ContentType; -use App\Enums\PostPlatform\Status as PostPlatformStatus; use App\Enums\SocialAccount\Platform; use App\Events\PostCreated; use App\Events\PostStatusChanged; diff --git a/tests/Feature/Posts/PostRecurrenceTest.php b/tests/Feature/Posts/PostRecurrenceTest.php index dbca60fd30..fd18c3ab30 100644 --- a/tests/Feature/Posts/PostRecurrenceTest.php +++ b/tests/Feature/Posts/PostRecurrenceTest.php @@ -4,10 +4,10 @@ use App\Actions\Post\FinalizePostPublication; use App\Actions\Post\UpdatePost; +use App\Enums\Post\PublishStatus as PostPlatformStatus; use App\Enums\Post\RecurrenceFrequency; use App\Enums\Post\ScheduleMode; use App\Enums\Post\Status as PostStatus; -use App\Enums\PostPlatform\Status as PostPlatformStatus; use App\Enums\SocialAccount\Platform; use App\Mcp\Servers\TryPostServer; use App\Mcp\Tools\Post\PublishPostTool; diff --git a/tests/Feature/Posts/PublishPageTest.php b/tests/Feature/Posts/PublishPageTest.php index 879e0bb14b..097a8d5c6a 100644 --- a/tests/Feature/Posts/PublishPageTest.php +++ b/tests/Feature/Posts/PublishPageTest.php @@ -4,9 +4,9 @@ use App\Actions\Post\BuildPublishPageProps; use App\Enums\Post\Origin; +use App\Enums\Post\PublishStatus as PlatformStatus; use App\Enums\Post\ScheduleMode; use App\Enums\Post\Status as PostStatus; -use App\Enums\PostPlatform\Status as PlatformStatus; use App\Enums\User\WeekStart; use App\Jobs\Analytics\BootstrapAccountAnalytics; use App\Jobs\Analytics\CollectAccountDailySnapshot; diff --git a/tests/Feature/Posts/PurgeOrphanedPostsTest.php b/tests/Feature/Posts/PurgeOrphanedPostsTest.php deleted file mode 100644 index 3d2eaa36fe..0000000000 --- a/tests/Feature/Posts/PurgeOrphanedPostsTest.php +++ /dev/null @@ -1,87 +0,0 @@ -published()->create($attributes); - $target = PostPlatform::factory()->published()->create(['post_id' => $post->id]); - $media = Media::factory()->ownedByPost($post)->create(); - Storage::put($media->path, 'bytes'); - SocialAccount::query()->whereKey($target->social_account_id)->delete(); - - return [$post, $target->fresh(), $media]; -} - -test('purging deletes posts left without a channel and their media, and is idempotent', function () { - Event::fake([PostDeleted::class]); - [$orphan, $target, $media] = orphanedPost(); - [$importedOrphan] = orphanedPost(['origin' => Origin::Network, 'user_id' => null]); - $publication = AnalyticsPublication::factory()->create(['workspace_id' => $orphan->workspace_id, 'post_platform_id' => $target->id]); - $live = PostPlatform::factory()->published()->create(); - - expect($target->social_account_id)->toBeNull(); - - $this->artisan('posts:purge-orphaned') - ->expectsOutputToContain('2 orphaned post(s) deleted, 0 orphaned target(s) removed from posts on other channels.') - ->assertSuccessful(); - - expect(Post::query()->whereKey([$orphan->id, $importedOrphan->id])->exists())->toBeFalse() - ->and(Media::query()->whereKey($media->id)->exists())->toBeFalse() - ->and($publication->fresh()->post_platform_id)->toBeNull() - ->and($publication->fresh()->post_dismissed_at)->toBeNull() - ->and(PostPlatform::query()->whereKey($live->id)->exists())->toBeTrue(); - Storage::assertMissing($media->path); - Event::assertNotDispatched(PostDeleted::class); - - $this->artisan('posts:purge-orphaned') - ->expectsOutputToContain('0 orphaned post(s) deleted, 0 orphaned target(s) removed from posts on other channels.') - ->assertSuccessful(); -}); - -test('a legacy post keeps its live channels and only loses the orphaned target', function () { - [$post, $orphanTarget] = orphanedPost(); - $live = PostPlatform::factory()->published()->create(['post_id' => $post->id]); - - $this->artisan('posts:purge-orphaned') - ->expectsOutputToContain('0 orphaned post(s) deleted, 1 orphaned target(s) removed from posts on other channels.') - ->assertSuccessful(); - - expect($post->fresh())->not->toBeNull() - ->and($post->postPlatforms()->pluck('id')->all())->toBe([$live->id]) - ->and(PostPlatform::query()->whereKey($orphanTarget->id)->exists())->toBeFalse(); -}); - -test('purging can be limited to one workspace', function () { - [$kept] = orphanedPost(); - [$purged] = orphanedPost(); - - $this->artisan('posts:purge-orphaned', ['--workspace' => $purged->workspace_id])->assertSuccessful(); - - expect(Post::query()->whereKey($kept->id)->exists())->toBeTrue() - ->and(Post::query()->whereKey($purged->id)->exists())->toBeFalse(); -}); - -test('the workspace option must be a uuid', function () { - $this->artisan('posts:purge-orphaned', ['--workspace' => 'nope'])->assertFailed(); -}); diff --git a/tests/Feature/ReleaseTryPost2Test.php b/tests/Feature/ReleaseTryPost2Test.php deleted file mode 100644 index dcdaf0c837..0000000000 --- a/tests/Feature/ReleaseTryPost2Test.php +++ /dev/null @@ -1,699 +0,0 @@ -workspace = Workspace::factory()->create(); -}); - -/** - * Fakes the jobs the release queues; called again after the fixtures so the - * jobs their observers queue are not counted. - */ -function releaseFakeQueue(): void -{ - Queue::fake([BackfillTryPostPublications::class, BootstrapAccountAnalytics::class, CollectAccountDailySnapshot::class, DiscoverAccountPublications::class]); -} - -function releaseLibraryAsset(Workspace $workspace): Media -{ - $asset = Media::factory()->libraryAsset($workspace)->create(['path' => 'medias/'.Str::uuid().'.jpg']); - Storage::put($asset->path, 'library bytes'); - - return $asset; -} - -/** - * @return array - */ -function releaseLibraryItem(Media $asset): array -{ - return ['id' => $asset->id, 'path' => $asset->path, 'url' => $asset->url, 'type' => 'image', 'mime_type' => 'image/jpeg']; -} - -/** - * A legacy scheduled post on two Instagram channels that uses a library file. - */ -function releaseLegacyPost(Workspace $workspace, Media $asset): Post -{ - $post = Post::factory()->scheduled()->create(['workspace_id' => $workspace->id, 'media' => [releaseLibraryItem($asset)]]); - $accounts = SocialAccount::factory()->count(2)->create(['workspace_id' => $workspace->id, 'platform' => Platform::Instagram]); - - foreach ($accounts as $account) { - PostPlatform::factory()->create(['post_id' => $post->id, 'social_account_id' => $account->id, 'platform' => $account->platform]); - } - - return $post; -} - -function releaseOrphanedPost(Workspace $workspace): Post -{ - $post = Post::factory()->published()->create(['workspace_id' => $workspace->id]); - $target = PostPlatform::factory()->published()->create(['post_id' => $post->id]); - SocialAccount::query()->whereKey($target->social_account_id)->delete(); - - return $post; -} - -/** - * The step outputs come from the commands the release calls, so their - * positions in the output give the order the steps ran in. - * - * @param array $options - * @return array{0: int, 1: string} - */ -function runRelease(array $options = []): array -{ - $output = new BufferedOutput; - $code = Artisan::call('release:trypost-2', ['--force' => true, ...$options], $output); - - return [$code, $output->fetch()]; -} - -/** - * @param list $needles - */ -function expectInOrder(string $output, array $needles): void -{ - $positions = array_map(fn (string $needle): int|false => strpos($output, $needle), $needles); - - expect($positions)->not->toContain(false); - - $sorted = $positions; - sort($sorted); - - expect($positions)->toBe($sorted); -} - -test('pending migrations abort the release before any step runs', function () { - $directory = storage_path('framework/testing/release-migrations'); - File::ensureDirectoryExists($directory); - File::put("{$directory}/2099_01_01_000000_release_probe.php", 'path($directory); - $asset = releaseLibraryAsset($this->workspace); - $post = releaseLegacyPost($this->workspace, $asset); - - try { - [$code, $output] = runRelease(); - } finally { - File::deleteDirectory($directory); - } - - expect($code)->toBe(Command::FAILURE) - ->and($output)->toContain('1 pending migration(s)') - ->and($output)->toContain('2099_01_01_000000_release_probe') - ->and($output)->not->toContain('[1/8]') - ->and(Media::query()->whereKey($asset->id)->exists())->toBeTrue() - ->and($post->postPlatforms()->count())->toBe(2); -}); - -test('a dry run prints every step and changes nothing', function () { - $asset = releaseLibraryAsset($this->workspace); - $post = releaseLegacyPost($this->workspace, $asset); - $orphan = releaseOrphanedPost($this->workspace); - - releaseFakeQueue(); - $files = Storage::allFiles(); - - [$code, $output] = runRelease(['--dry-run' => true]); - - expect($code)->toBe(Command::SUCCESS) - ->and(Storage::allFiles())->toBe($files) - ->and($output)->toMatch('/\| Media library adoption\s+\| dry run\s+\|/') - ->and($output)->toMatch('/\| Media audit \(read-only\)\s+\| dry run\s+\|/'); - expectInOrder($output, [ - 'Would run: php artisan posts:purge-orphaned', - 'would delete 1 post(s) and detach 0 target(s)', - 'Would run: php artisan posts:split-legacy-active', - 'Editable with multiple enabled targets', - 'Would run: php artisan posts:bake-aspect-ratio-crops', - 'would bake the aspect ratio crop of 0 scheduled or in-flight post(s)', - 'References to copy', - 'Would run: php artisan analytics:backfill-existing --chunk=100 --delay=0', - 'Would run: php artisan analytics:dispatch-publication-discovery --platform=instagram --platform=instagram-facebook', - '| json_drift', - 'Dry run: nothing was changed.', - ]); - - expect(Media::query()->whereKey($asset->id)->exists())->toBeTrue() - ->and(Post::query()->count())->toBe(2) - ->and($post->postPlatforms()->count())->toBe(2) - ->and(Post::query()->whereKey($orphan->id)->exists())->toBeTrue(); - Queue::assertNothingPushed(); -}); - -test('a full run executes the steps in order, waits for the queued adoption and is a no-op the second time', function () { - $asset = releaseLibraryAsset($this->workspace); - releaseLegacyPost($this->workspace, $asset); - $orphan = releaseOrphanedPost($this->workspace); - - releaseFakeQueue(); - - [$code, $output] = runRelease(['--include-unsubscribed' => true, '--backfill-chunk' => 10, '--backfill-delay' => 60]); - - expect($code)->toBe(Command::SUCCESS); - expectInOrder($output, [ - '1 orphaned post(s) deleted', - 'Split 1 original posts and created 1 independent posts.', - '1/1 workspace(s) done, 0 library row(s) left, 0 stopped', - 'accounts_dispatched=2', - '[7/8] Instagram stories discovery', - '| orphaned_files', - '0 unexpected', - 'Release steps done.', - ]); - - expect(Media::query()->whereKey($asset->id)->exists())->toBeFalse() - ->and(Post::query()->whereKey($orphan->id)->exists())->toBeFalse(); - - $posts = Post::query()->where('workspace_id', $this->workspace->id)->get(); - - expect($posts)->toHaveCount(2); - - foreach ($posts as $owner) { - $copy = Media::query()->findOrFail(data_get($owner->media, '0.id')); - - expect($copy->post_id)->toBe($owner->id) - ->and($owner->postPlatforms()->enabled()->count())->toBe(1); - } - - Queue::assertPushed(BootstrapAccountAnalytics::class, 2); - - [$code, $output] = runRelease(); - - expect($code)->toBe(Command::SUCCESS); - expectInOrder($output, [ - '0 orphaned post(s) deleted', - 'Split 0 original posts and created 0 independent posts.', - 'No library media to adopt.', - 'Skipped: already dispatched at', - '0 expected, 0 unexpected', - ]); - - expect(Post::query()->where('workspace_id', $this->workspace->id)->count())->toBe(2) - ->and(Media::query()->where('workspace_id', $this->workspace->id)->count())->toBe(2); - Queue::assertPushed(BootstrapAccountAnalytics::class, 2); - - runRelease(['--include-unsubscribed' => true, '--rerun-backfill' => true]); - - Queue::assertPushed(BootstrapAccountAnalytics::class, 4); -}); - -test('the adoption dispatches one job per workspace on its own queue and waits until every library is adopted', function () { - $other = Workspace::factory()->create(); - $assets = [releaseLibraryAsset($this->workspace), releaseLibraryAsset($other)]; - Post::factory()->published()->create(['workspace_id' => $this->workspace->id, 'media' => [releaseLibraryItem($assets[0])]]); - Post::factory()->published()->create(['workspace_id' => $other->id, 'media' => [releaseLibraryItem($assets[1])]]); - releaseFakeQueue(); - Queue::fake([AdoptWorkspaceLibraryJob::class, BootstrapAccountAnalytics::class, CollectAccountDailySnapshot::class, DiscoverAccountPublications::class, BackfillTryPostPublications::class]); - Sleep::fake(syncWithCarbon: true); - Sleep::whenFakingSleep(function (): void { - Queue::pushed(AdoptWorkspaceLibraryJob::class)->each(function (AdoptWorkspaceLibraryJob $job): void { - $job->handle(); - (new UniqueLock(Cache::store()))->release($job); - }); - }); - - [$code, $output] = runRelease(); - - expect($code)->toBe(Command::SUCCESS, $output) - ->and($output)->toContain('0/2 workspace(s) done, 2 library row(s) left, 0 stopped') - ->and($output)->toContain('2/2 workspace(s) done, 0 library row(s) left, 0 stopped') - ->and($output)->toContain('2 of 2 workspace(s) adopted') - ->and(Media::query()->whereKey([$assets[0]->id, $assets[1]->id])->exists())->toBeFalse(); - Queue::assertPushed(AdoptWorkspaceLibraryJob::class, 2); - Queue::assertPushedOn(AdoptWorkspaceLibraryJob::QUEUE, AdoptWorkspaceLibraryJob::class); - Sleep::assertSleptTimes(1); -}); - -test('a workspace still adopting when the timeout passes is left to its job and the release goes on', function () { - Sleep::fake(syncWithCarbon: true); - $asset = releaseLibraryAsset($this->workspace); - Post::factory()->create(['workspace_id' => $this->workspace->id, 'status' => Status::Publishing, 'media' => [releaseLibraryItem($asset)]]); - - [$code, $output] = runRelease(['--adoption-timeout' => 60]); - - expect($code)->toBe(Command::SUCCESS, $output) - ->and($output)->toContain('1 workspace(s) still adopting after 60s') - ->and($output)->toContain('[7/8] Instagram stories discovery') - ->and($output)->toContain('| json_drift | 1 | 0 |') - ->and(Media::query()->whereKey($asset->id)->exists())->toBeTrue(); - Sleep::assertSleptTimes(4); -}); - -test('a workspace whose job stops without adopting is reported as failed and the release goes on', function () { - $healthyAsset = releaseLibraryAsset($this->workspace); - $healthyPost = Post::factory()->published()->create(['workspace_id' => $this->workspace->id, 'media' => [releaseLibraryItem($healthyAsset)]]); - $broken = Workspace::factory()->create(); - $brokenAsset = releaseLibraryAsset($broken); - Storage::delete($brokenAsset->path); - Post::factory()->published()->create(['workspace_id' => $broken->id, 'media' => [releaseLibraryItem($brokenAsset)]]); - - releaseFakeQueue(); - - [$code, $output] = runRelease(); - - expect($code)->toBe(Command::FAILURE) - ->and($output)->toContain("1 workspace(s) stopped with library media left: {$broken->id}") - ->and($output)->toContain('[7/8] Instagram stories discovery') - ->and($output)->toContain('| Media audit (read-only)') - ->and($output)->toContain('1 failed step(s)') - ->and(Media::query()->whereKey($healthyAsset->id)->exists())->toBeFalse() - ->and(Media::query()->findOrFail(data_get($healthyPost->fresh()->media, '0.id'))->post_id)->toBe($healthyPost->id) - ->and(Media::query()->whereKey($brokenAsset->id)->exists())->toBeTrue(); -}); - -test('a legacy instagram post scheduled with two crops on a shared library photo is baked per channel once the release splits it', function () { - $asset = Media::factory()->libraryAsset($this->workspace)->create(['path' => 'medias/'.Str::uuid().'.jpg', 'meta' => ['width' => 1080, 'height' => 1920]]); - $image = imagecreatetruecolor(1080, 1920); - ob_start(); - imagejpeg($image); - Storage::put($asset->path, (string) ob_get_clean()); - $post = Post::factory()->scheduled()->create(['workspace_id' => $this->workspace->id, 'media' => [releaseLibraryItem($asset)]]); - $ratios = ['4:5' => [1080, 1350], '1:1' => [1080, 1080]]; - $expected = []; - - foreach ($ratios as $ratio => $dimensions) { - $account = SocialAccount::factory()->instagram()->create(['workspace_id' => $this->workspace->id]); - $expected[$account->id] = $dimensions; - PostPlatform::factory()->create([ - 'post_id' => $post->id, - 'social_account_id' => $account->id, - 'platform' => Platform::Instagram, - 'content_type' => ContentType::InstagramFeed, - 'meta' => ['aspect_ratio' => $ratio], - ]); - } - - releaseFakeQueue(); - - $this->artisan('posts:bake-aspect-ratio-crops') - ->expectsOutputToContain('Baked 0 cropped image(s) into 0 post(s)') - ->expectsOutputToContain("uncropped for now (their media is shared; they keep their aspect ratio until a run after posts:split-legacy-active): {$post->id}") - ->assertSuccessful(); - - [$code, $output] = runRelease(); - - expect($code)->toBe(Command::SUCCESS, $output); - expectInOrder($output, [ - 'Split 1 original posts and created 1 independent posts.', - 'Baked 2 cropped image(s) into 2 post(s)', - '1/1 workspace(s) done', - '0 unexpected', - ]); - - $targets = PostPlatform::query()->with('post')->whereIn('post_id', Post::query()->where('workspace_id', $this->workspace->id)->select('id'))->get(); - - expect($targets)->toHaveCount(2) - ->and(Media::query()->whereKey($asset->id)->exists())->toBeFalse(); - - foreach ($targets as $target) { - $owner = $target->post; - $row = Media::query()->findOrFail(data_get($owner->media, '0.id')); - [$width, $height] = $expected[$target->social_account_id]; - - expect($target->meta)->toEqual([]) - ->and($row->post_id)->toBe($owner->id) - ->and(getimagesizefromstring((string) Storage::get($row->path)))->toMatchArray([0 => $width, 1 => $height]); - } - - [$code, $output] = runRelease(); - - expect($code)->toBe(Command::SUCCESS, $output) - ->and($output)->toContain('Baked 0 cropped image(s) into 0 post(s)'); -}); - -test('a legacy instagram draft on two channels is split without a crop and loses the aspect ratio', function () { - $asset = Media::factory()->libraryAsset($this->workspace)->create(['path' => 'medias/'.Str::uuid().'.jpg', 'meta' => ['width' => 1080, 'height' => 1920]]); - $image = imagecreatetruecolor(1080, 1920); - ob_start(); - imagejpeg($image); - Storage::put($asset->path, (string) ob_get_clean()); - $post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'status' => Status::Draft, 'media' => [releaseLibraryItem($asset)]]); - - foreach (['4:5', '1:1'] as $ratio) { - $account = SocialAccount::factory()->instagram()->create(['workspace_id' => $this->workspace->id]); - PostPlatform::factory()->create([ - 'post_id' => $post->id, - 'social_account_id' => $account->id, - 'platform' => Platform::Instagram, - 'content_type' => ContentType::InstagramFeed, - 'meta' => ['aspect_ratio' => $ratio], - ]); - } - - releaseFakeQueue(); - - [$code, $output] = runRelease(); - - expect($code)->toBe(Command::SUCCESS, $output) - ->and($output)->toContain('Baked 0 cropped image(s) into 0 post(s)'); - - $targets = PostPlatform::query()->with('post')->whereIn('post_id', Post::query()->where('workspace_id', $this->workspace->id)->select('id'))->get(); - - expect($targets)->toHaveCount(2) - ->and($targets->pluck('post_id')->unique())->toHaveCount(2); - - foreach ($targets as $target) { - $row = Media::query()->findOrFail(data_get($target->post->media, '0.id')); - - expect($target->meta)->toEqual([]) - ->and($target->post->status)->toBe(Status::Draft) - ->and(getimagesizefromstring((string) Storage::get($row->path)))->toMatchArray([0 => 1080, 1 => 1920]); - } -}); - -test('the final audit counts what the release leaves behind on purpose as expected and passes', function () { - Queue::fake([DeleteMediaFiles::class]); - $asset = releaseLibraryAsset($this->workspace); - $other = Workspace::factory()->create(); - Post::factory()->published()->create(['workspace_id' => $other->id, 'media' => [releaseLibraryItem($asset)]]); - Post::factory()->published()->create(['workspace_id' => $this->workspace->id, 'media' => [['id' => (string) Str::uuid(), 'path' => 'medias/gone.jpg', 'url' => 'https://cdn.example/gone.jpg', 'type' => 'image']]]); - $unused = releaseLibraryAsset($this->workspace); - Storage::put('medias/stray-before-the-release.jpg', 'stray'); - - [$code, $output] = runRelease(); - - expect($code)->toBe(Command::SUCCESS, $output) - ->and($output)->toContain('| orphaned_files | 2 | 0 |') - ->and($output)->toContain('| json_drift | 2 | 0 |') - ->and($output)->toContain('0 unexpected') - ->and(Media::query()->whereKey($asset->id)->exists())->toBeTrue() - ->and(Media::query()->whereKey($unused->id)->exists())->toBeFalse(); - Storage::assertExists($unused->path); -}); - -test('an audit finding the release did not expect fails it and is listed', function () { - $asset = releaseLibraryAsset($this->workspace); - $post = Post::factory()->published()->create(['workspace_id' => $this->workspace->id, 'media' => [releaseLibraryItem($asset)]]); - Media::created(function (Media $media): void { - Storage::delete($media->path); - }); - - [$code, $output] = runRelease(); - - $copy = Media::query()->findOrFail(data_get($post->fresh()->media, '0.id')); - - expect($code)->toBe(Command::FAILURE) - ->and($output)->toContain("unexpected missing_files: media_id={$copy->id}") - ->and($output)->toContain('1 unexpected audit finding(s)'); -}); - -test('a referenced media row the release deletes by mistake is an unexpected finding', function () { - $asset = releaseLibraryAsset($this->workspace); - Post::factory()->published()->create(['workspace_id' => $this->workspace->id, 'media' => [releaseLibraryItem($asset)]]); - $kept = Post::factory()->published()->create(['workspace_id' => $this->workspace->id]); - $victim = Media::factory()->ownedByPost($kept)->create(['path' => 'medias/'.Str::uuid().'.jpg']); - Storage::put($victim->path, 'victim bytes'); - $kept->forceFill(['media' => [releaseLibraryItem($victim)]])->saveQuietly(); - Media::created(function () use ($victim): void { - DB::table('medias')->where('id', $victim->id)->delete(); - }); - - [$code, $output] = runRelease(); - - expect($code)->toBe(Command::FAILURE) - ->and($output)->toContain("media_id={$victim->id} problem=json_without_row") - ->and($output)->toContain('unexpected audit finding(s)'); -}); - -test('the backfill marker lives in the database cache, so a flushed default cache does not dispatch it again', function () { - SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::Instagram]); - releaseFakeQueue(); - - runRelease(['--include-unsubscribed' => true]); - Cache::flush(); - [, $output] = runRelease(['--include-unsubscribed' => true]); - - expect(Cache::store('database')->get(ReleaseTryPost2::BACKFILL_DISPATCHED_KEY))->not->toBeNull() - ->and($output)->toContain('Skipped: already dispatched at'); - Queue::assertPushed(BootstrapAccountAnalytics::class, 1); -}); - -test('a failure while queueing the adoption is reported and the release goes on', function () { - Sleep::fake(); - $asset = releaseLibraryAsset($this->workspace); - Post::factory()->published()->create(['workspace_id' => $this->workspace->id, 'media' => [releaseLibraryItem($asset)]]); - Artisan::command('media:adopt-library {--force} {--dry-run}', function (): void { - throw new RuntimeException('queue is down'); - }); - - [$code, $output] = runRelease(); - - expect($code)->toBe(Command::FAILURE) - ->and($output)->toContain('Could not queue AdoptWorkspaceLibraryJob: queue is down') - ->and($output)->toContain('[8/8] Media audit') - ->and($output)->toContain('| Media library adoption') - ->and(Media::query()->whereKey($asset->id)->exists())->toBeTrue(); - Sleep::assertNeverSlept(); -}); - -test('a partially published post with a target on a deleted channel ends as one published and one failed post', function () { - $post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'status' => Status::PartiallyPublished, 'content' => 'Launch video']); - $youtube = PostPlatform::factory()->youtube()->published()->create(['post_id' => $post->id, 'social_account_id' => SocialAccount::factory()->create(['workspace_id' => $this->workspace->id])->id]); - $instagram = PostPlatform::factory()->instagram()->failed()->create(['post_id' => $post->id, 'social_account_id' => SocialAccount::factory()->create(['workspace_id' => $this->workspace->id])->id]); - $tiktok = PostPlatform::factory()->tiktok()->failed()->create(['post_id' => $post->id, 'social_account_id' => SocialAccount::factory()->create(['workspace_id' => $this->workspace->id])->id]); - $publication = AnalyticsPublication::factory()->create(['workspace_id' => $this->workspace->id, 'post_platform_id' => $youtube->id]); - $tiktok->socialAccount->delete(); - - releaseFakeQueue(); - - [$code, $output] = runRelease(); - - $posts = Post::query()->where('workspace_id', $this->workspace->id)->with('postPlatforms')->get()->keyBy(fn (Post $split): string => $split->postPlatforms->sole()->id); - - expect($code)->toBe(Command::SUCCESS, $output) - ->and($posts->keys()->sort()->values()->all())->toBe(collect([$youtube->id, $instagram->id])->sort()->values()->all()) - ->and($posts[$youtube->id]->status)->toBe(Status::Published) - ->and($posts[$instagram->id]->status)->toBe(Status::Failed) - ->and($posts[$youtube->id]->post_group_id)->toBe($posts[$instagram->id]->post_group_id) - ->and($posts->pluck('content')->unique()->all())->toBe(['Launch video']) - ->and(PostPlatform::query()->whereKey($tiktok->id)->exists())->toBeFalse() - ->and($publication->fresh()->post_platform_id)->toBe($youtube->id) - ->and($publication->fresh()->postPlatform->post_id)->toBe($posts[$youtube->id]->id); -}); - -/** - * Everything a second run could change, in a comparable shape. - * - * @return array - */ -function releaseFingerprint(): array -{ - return [ - 'posts' => Post::query()->orderBy('id')->get()->map(fn (Post $post): array => [ - $post->id, $post->workspace_id, $post->post_group_id, $post->content, $post->media, $post->status->value, $post->scheduled_at?->toIso8601String(), - ])->all(), - 'targets' => PostPlatform::query()->orderBy('id')->get()->map(fn (PostPlatform $target): array => [ - $target->id, $target->post_id, $target->social_account_id, $target->enabled, $target->status->value, $target->meta, - ])->all(), - 'media' => Media::query()->orderBy('id')->get()->map(fn (Media $media): array => [ - $media->id, $media->collection, $media->workspace_id, $media->post_id, $media->path, - ])->all(), - 'labels' => DB::table('post_workspace_label')->orderBy('post_id')->orderBy('workspace_label_id')->get(['post_id', 'workspace_label_id'])->map(fn (object $row): array => (array) $row)->all(), - 'notes' => PostNote::query()->orderBy('id')->get(['id', 'post_id', 'body'])->toArray(), - 'files' => collect(Storage::allFiles())->sort()->values()->all(), - ]; -} - -test('a realistic pre-release workspace keeps every post, target, label, note and file it should after the release, and a second run changes nothing', function (bool $adoptedOnBoot) { - $workspace = $this->workspace; - $workspace->owner->forceFill(['timezone' => 'America/Sao_Paulo'])->saveQuietly(); - $accounts = SocialAccount::factory()->count(3)->create(['workspace_id' => $workspace->id]); - $deadAccount = SocialAccount::factory()->create(['workspace_id' => $workspace->id]); - [$launch, $promo] = WorkspaceLabel::factory()->count(2)->create(['workspace_id' => $workspace->id])->all(); - $scheduledAt = now()->addDays(3)->startOfSecond(); - - $shared = releaseLibraryAsset($workspace); - $draftOnly = releaseLibraryAsset($workspace); - $orphanOnly = releaseLibraryAsset($workspace); - $unused = releaseLibraryAsset($workspace); - $libraryPaths = [$shared->path, $draftOnly->path, $orphanOnly->path, $unused->path]; - - $target = fn (Post $post, SocialAccount $account, array $attributes = []): PostPlatform => PostPlatform::factory()->create([ - 'post_id' => $post->id, - 'social_account_id' => $account->id, - 'meta' => ['first_comment' => "comment for {$account->id}"], - ...$attributes, - ]); - - $draft = Post::factory()->draft()->create([ - 'workspace_id' => $workspace->id, - 'content' => 'Draft caption', - 'media' => [ - [...releaseLibraryItem($shared), 'meta' => ['alt_text' => 'Shared alt']], - [...releaseLibraryItem($draftOnly), 'meta' => ['alt_text' => 'Draft alt']], - ], - ]); - $target($draft, $accounts[0]); - $target($draft, $accounts[1], ['content_type' => ContentType::LinkedInPost]); - $draft->labels()->attach($launch); - - $scheduled = Post::factory()->scheduled()->create([ - 'workspace_id' => $workspace->id, - 'content' => 'Scheduled caption', - 'scheduled_at' => $scheduledAt, - 'media' => [releaseLibraryItem($shared)], - ]); - $target($scheduled, $accounts[0]); - $target($scheduled, $accounts[1]); - $disabled = PostPlatform::factory()->disabled()->create(['post_id' => $scheduled->id, 'social_account_id' => $accounts[2]->id]); - $scheduled->labels()->attach([$launch->id, $promo->id]); - $note = PostNote::factory()->create(['post_id' => $scheduled->id, 'user_id' => $scheduled->user_id]); - - $published = Post::factory()->published()->create([ - 'workspace_id' => $workspace->id, - 'content' => 'Published caption', - 'media' => [releaseLibraryItem($shared)], - ]); - PostPlatform::factory()->published()->create(['post_id' => $published->id, 'social_account_id' => $accounts[0]->id]); - PostPlatform::factory()->failed()->create(['post_id' => $published->id, 'social_account_id' => $accounts[1]->id]); - - $orphan = Post::factory()->published()->create(['workspace_id' => $workspace->id, 'media' => [releaseLibraryItem($orphanOnly)]]); - PostPlatform::factory()->published()->create(['post_id' => $orphan->id, 'social_account_id' => $deadAccount->id]); - - $mixed = Post::factory()->scheduled()->create(['workspace_id' => $workspace->id, 'content' => 'Mixed caption', 'scheduled_at' => $scheduledAt]); - $deadTarget = $target($mixed, $deadAccount); - $liveTarget = $target($mixed, $accounts[2]); - - $other = Workspace::factory()->create(); - $otherAccount = SocialAccount::factory()->create(['workspace_id' => $other->id]); - $otherPost = Post::factory()->scheduled()->create(['workspace_id' => $other->id, 'content' => 'Untouched caption']); - $otherMedia = Media::factory()->ownedByPost($otherPost)->create(['path' => 'medias/'.Str::uuid().'.jpg']); - Storage::put($otherMedia->path, 'owned bytes'); - $otherPost->forceFill(['media' => [releaseLibraryItem($otherMedia)]])->saveQuietly(); - PostPlatform::factory()->create(['post_id' => $otherPost->id, 'social_account_id' => $otherAccount->id]); - - $deadAccount->delete(); - - $originals = collect([$draft, $scheduled, $published, $mixed, $otherPost])->mapWithKeys(fn (Post $post): array => [$post->id => [ - 'content' => $post->content, - 'status' => $post->status, - 'scheduled_at' => $post->scheduled_at?->toIso8601String(), - 'labels' => $post->labels()->pluck('workspace_labels.id')->sort()->values()->all(), - 'items' => collect($post->fresh()->media)->map(fn (array $item): array => [ - 'fields' => Arr::except($item, ['id', 'path', 'url']), - 'bytes' => Storage::get($item['path']), - ])->all(), - ]]); - $targets = PostPlatform::query()->whereNotNull('social_account_id')->get()->keyBy('id') - ->map(fn (PostPlatform $row): array => [$row->post_id, $row->social_account_id, $row->enabled, $row->status, $row->content_type, $row->meta]); - - if ($adoptedOnBoot) { - AdoptWorkspaceLibrary::execute($workspace); - } - - releaseFakeQueue(); - - [$code, $output] = runRelease(); - - expect($code)->toBe(Command::SUCCESS, $output); - - expect(Post::query()->whereKey($orphan->id)->exists())->toBeFalse() - ->and(PostPlatform::query()->whereKey($deadTarget->id)->exists())->toBeFalse() - ->and(Post::query()->where('workspace_id', $workspace->id)->count())->toBe(7) - ->and(Media::query()->where('collection', Media::LIBRARY_COLLECTION)->exists())->toBeFalse(); - - foreach ($libraryPaths as $path) { - Storage::assertMissing($path); - } - - foreach ($targets as $id => [$postId, $accountId, $enabled, $status, $contentType, $meta]) { - $row = PostPlatform::query()->findOrFail($id); - $original = $originals[$postId]; - $owner = $row->post; - - expect([$row->social_account_id, $row->enabled, $row->status, $row->content_type, $row->meta])->toEqual([$accountId, $enabled, $status, $contentType, $meta]) - ->and($owner->content)->toBe($original['content']) - ->and($owner->status)->toBe($original['status']->isSettled() ? ($status === PostPlatformStatus::Published ? Status::Published : Status::Failed) : $original['status']) - ->and($owner->scheduled_at?->toIso8601String())->toBe($original['scheduled_at']) - ->and($owner->labels()->pluck('workspace_labels.id')->sort()->values()->all())->toBe($original['labels']); - - foreach ($owner->media as $index => $item) { - $row = Media::query()->findOrFail($item['id']); - - expect(Arr::except($item, ['id', 'path', 'url']))->toEqual($original['items'][$index]['fields']) - ->and($row->post_id)->toBe($owner->id) - ->and($row->workspace_id)->toBe($owner->workspace_id) - ->and($row->path)->toBe($item['path']) - ->and(Storage::get($item['path']))->toBe($original['items'][$index]['bytes']); - } - - expect($owner->media)->toHaveCount(count($original['items'])); - } - - foreach ([$draft, $scheduled] as $legacy) { - $group = Post::query()->where('post_group_id', $legacy->fresh()->post_group_id)->get(); - - expect($legacy->fresh()->post_group_id)->not->toBeNull() - ->and($group)->toHaveCount(2) - ->and($group->every(fn (Post $post): bool => $post->postPlatforms()->enabled()->count() === 1))->toBeTrue(); - } - - expect(PostPlatform::query()->findOrFail($disabled->id)->post_id)->toBe($scheduled->id) - ->and(PostNote::query()->whereIn('post_id', Post::query()->where('post_group_id', $scheduled->fresh()->post_group_id)->select('id'))->pluck('body')->all())->toBe([$note->body, $note->body]) - ->and($published->fresh()->post_group_id)->not->toBeNull() - ->and(Post::query()->where('post_group_id', $published->fresh()->post_group_id)->pluck('status')->map->value->sort()->values()->all())->toBe([Status::Failed->value, Status::Published->value]) - ->and(Post::query()->where('status', Status::PartiallyPublished)->exists())->toBeFalse() - ->and($mixed->fresh()->postPlatforms()->pluck('id')->all())->toBe([$liveTarget->id]) - ->and($otherPost->fresh()->media)->toEqual([releaseLibraryItem($otherMedia)]) - ->and($otherMedia->fresh()->post_id)->toBe($otherPost->id); - Storage::assertExists($otherMedia->path); - - Media::query()->each(fn (Media $media) => Storage::assertExists($media->path)); - - foreach ($accounts as $account) { - $account = $account->fresh(); - - expect($account->timezone)->toBe('America/Sao_Paulo') - ->and($account->posting_goal)->toBe(3) - ->and($account->posting_schedule->slotCount())->toBe(3); - } - - $fingerprint = releaseFingerprint(); - - [$code, $output] = runRelease(); - - expect($code)->toBe(Command::SUCCESS, $output) - ->and(releaseFingerprint())->toEqual($fingerprint); -})->with([ - 'library adopted by the release' => [false], - 'library already adopted on boot, before the split' => [true], -]); diff --git a/tests/Feature/Repurpose/WebTest.php b/tests/Feature/Repurpose/WebTest.php index f425e97ba5..f19dafe203 100644 --- a/tests/Feature/Repurpose/WebTest.php +++ b/tests/Feature/Repurpose/WebTest.php @@ -2,8 +2,8 @@ declare(strict_types=1); +use App\Enums\Post\PublishStatus as PostPlatformStatus; use App\Enums\PostPlatform\ContentType; -use App\Enums\PostPlatform\Status as PostPlatformStatus; use App\Enums\Repurpose\PublishMode; use App\Enums\Repurpose\SourceFormat; use App\Enums\Repurpose\Status; diff --git a/tests/Feature/Services/Social/InstagramPublisherTest.php b/tests/Feature/Services/Social/InstagramPublisherTest.php index 58ead9d65b..0b2f5c2ea0 100644 --- a/tests/Feature/Services/Social/InstagramPublisherTest.php +++ b/tests/Feature/Services/Social/InstagramPublisherTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); +use App\Enums\Post\PublishStatus as PlatformStatus; use App\Enums\Post\Status as PostStatus; use App\Enums\PostPlatform\ContentType; -use App\Enums\PostPlatform\Status as PlatformStatus; use App\Enums\SocialAccount\Platform; use App\Exceptions\PlatformUnavailableException; use App\Exceptions\Social\ErrorCategory; diff --git a/tests/Feature/Services/Social/TikTokAnalyticsTest.php b/tests/Feature/Services/Social/TikTokAnalyticsTest.php index b93765319b..c8e0c45950 100644 --- a/tests/Feature/Services/Social/TikTokAnalyticsTest.php +++ b/tests/Feature/Services/Social/TikTokAnalyticsTest.php @@ -3,7 +3,7 @@ declare(strict_types=1); use App\Actions\Analytics\ReadPublicationAnalytics; -use App\Enums\PostPlatform\Status as PostPlatformStatus; +use App\Enums\Post\PublishStatus as PostPlatformStatus; use App\Enums\SocialAccount\Platform; use App\Enums\TikTok\PrivacyLevel; use App\Models\AnalyticsPublication; diff --git a/tests/Feature/Services/Social/XPublisherTest.php b/tests/Feature/Services/Social/XPublisherTest.php index 16c4fed2ef..c20bb7d075 100644 --- a/tests/Feature/Services/Social/XPublisherTest.php +++ b/tests/Feature/Services/Social/XPublisherTest.php @@ -2,8 +2,8 @@ declare(strict_types=1); +use App\Enums\Post\PublishStatus as PlatformStatus; use App\Enums\PostPlatform\ContentType; -use App\Enums\PostPlatform\Status as PlatformStatus; use App\Enums\SocialAccount\Platform; use App\Exceptions\PlatformUnavailableException; use App\Exceptions\Social\ContentLimitException; diff --git a/tests/Feature/SocialAccount/NetworkUniquenessTest.php b/tests/Feature/SocialAccount/NetworkUniquenessTest.php index 3314e1975c..845c393b5a 100644 --- a/tests/Feature/SocialAccount/NetworkUniquenessTest.php +++ b/tests/Feature/SocialAccount/NetworkUniquenessTest.php @@ -2,8 +2,8 @@ declare(strict_types=1); +use App\Enums\Post\PublishStatus as PostPlatformStatus; use App\Enums\PostPlatform\ContentType; -use App\Enums\PostPlatform\Status as PostPlatformStatus; use App\Enums\SocialAccount\Platform; use App\Enums\SocialAccount\Status; use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException; diff --git a/tests/Feature/SocialControllerTest.php b/tests/Feature/SocialControllerTest.php index a344e6d7a5..c4b7ddbda6 100644 --- a/tests/Feature/SocialControllerTest.php +++ b/tests/Feature/SocialControllerTest.php @@ -2,8 +2,8 @@ declare(strict_types=1); +use App\Enums\Post\PublishStatus as PlatformStatus; use App\Enums\Post\Status; -use App\Enums\PostPlatform\Status as PlatformStatus; use App\Enums\SocialAccount\Platform; use App\Jobs\SendNotification; use App\Models\Post; diff --git a/tests/Feature/SplitLegacyActivePostsTest.php b/tests/Feature/SplitLegacyActivePostsTest.php deleted file mode 100644 index dba1be25c5..0000000000 --- a/tests/Feature/SplitLegacyActivePostsTest.php +++ /dev/null @@ -1,424 +0,0 @@ -create(); - $workspace = Workspace::factory()->create(['user_id' => $user->id]); - $post = Post::factory()->create([ - 'workspace_id' => $workspace->id, - 'user_id' => $user->id, - 'status' => Status::Scheduled, - 'scheduled_at' => now()->addDay(), - 'content' => 'Legacy shared caption', - 'media' => [['id' => 'media-id', 'path' => 'posts/photo.jpg']], - ]); - $accounts = SocialAccount::factory()->count(3)->create(['workspace_id' => $workspace->id]); - $first = PostPlatform::factory()->create(['post_id' => $post->id, 'social_account_id' => $accounts[0]->id]); - $second = PostPlatform::factory()->create(['post_id' => $post->id, 'social_account_id' => $accounts[1]->id]); - $disabled = PostPlatform::factory()->disabled()->create(['post_id' => $post->id, 'social_account_id' => $accounts[2]->id]); - $label = WorkspaceLabel::factory()->create(['workspace_id' => $workspace->id]); - $post->labels()->attach($label); - $comment = PostNote::factory()->create(['post_id' => $post->id, 'user_id' => $user->id]); - $older = PostNote::factory()->create([ - 'post_id' => $post->id, - 'user_id' => $user->id, - 'updated_at' => now()->subDays(3), - ]); - - Event::fake([PostCreated::class]); - $this->artisan('posts:split-legacy-active')->assertSuccessful(); - $this->artisan('posts:split-legacy-active')->assertSuccessful(); - - $clone = Post::whereKeyNot($post->id)->sole(); - expect(Post::count())->toBe(2) - ->and($first->fresh()->post_id)->toBe($post->id) - ->and($second->fresh()->post_id)->toBe($clone->id) - ->and($disabled->fresh()->post_id)->toBe($post->id) - ->and($clone->content)->toBe($post->content) - ->and($clone->media)->toEqual($post->media) - ->and($clone->scheduled_at->equalTo($post->scheduled_at))->toBeTrue() - ->and($clone->labels()->pluck('workspace_labels.id')->all())->toBe([$label->id]) - ->and($clone->notes()->count())->toBe(2) - ->and($clone->notes()->where('body', $comment->body)->exists())->toBeTrue() - ->and($clone->notes()->where('body', $older->body)->sole()->updated_at->equalTo($older->updated_at))->toBeTrue(); - Event::assertNotDispatched(PostCreated::class); -}); - -test('split leaves in-flight aggregates and settled ones with an unfinished target untouched', function () { - $user = User::factory()->create(); - $workspace = Workspace::factory()->create(['user_id' => $user->id]); - $accounts = SocialAccount::factory()->count(2)->create(['workspace_id' => $workspace->id]); - $publishing = Post::factory()->create(['workspace_id' => $workspace->id, 'user_id' => $user->id, 'status' => Status::Publishing]); - $unfinished = Post::factory()->create(['workspace_id' => $workspace->id, 'user_id' => $user->id, 'status' => Status::PartiallyPublished]); - foreach ($accounts as $account) { - PostPlatform::factory()->create(['post_id' => $publishing->id, 'social_account_id' => $account->id]); - } - PostPlatform::factory()->published()->create(['post_id' => $unfinished->id, 'social_account_id' => $accounts[0]->id]); - PostPlatform::factory()->create(['post_id' => $unfinished->id, 'social_account_id' => $accounts[1]->id, 'status' => PostPlatformStatus::PendingReview]); - - $this->artisan('posts:split-legacy-active') - ->expectsOutputToContain('Left 1 settled post(s) with an unfinished target as they are.') - ->assertSuccessful(); - - expect(Post::count())->toBe(2) - ->and(PostPlatform::count())->toBe(4) - ->and($unfinished->fresh()->status)->toBe(Status::PartiallyPublished); -}); - -test('the original post remains editable after a split with a disabled target', function () { - $user = User::factory()->create(); - $workspace = Workspace::factory()->create(['user_id' => $user->id]); - $accounts = SocialAccount::factory()->linkedin()->count(3)->create(['workspace_id' => $workspace->id]); - $post = Post::factory()->create([ - 'workspace_id' => $workspace->id, - 'user_id' => $user->id, - 'status' => Status::Draft, - 'content' => 'Original caption', - 'media' => [], - ]); - $first = PostPlatform::factory()->create(['post_id' => $post->id, 'social_account_id' => $accounts[0]->id]); - $second = PostPlatform::factory()->create(['post_id' => $post->id, 'social_account_id' => $accounts[1]->id]); - $disabled = PostPlatform::factory()->disabled()->create(['post_id' => $post->id, 'social_account_id' => $accounts[2]->id]); - - $this->artisan('posts:split-legacy-active')->assertSuccessful(); - UpdatePost::execute($workspace, $post->fresh(), [ - 'status' => Status::Draft->value, - 'content' => 'Changed caption', - 'content_type' => $first->content_type->value, - ]); - - expect($post->fresh()->content)->toBe('Changed caption') - ->and($second->fresh()->post->content)->toBe('Original caption') - ->and($disabled->fresh()->enabled)->toBeFalse(); - - expect(fn () => UpdatePost::execute($workspace, $post->fresh(), [ - 'platforms' => [['id' => $disabled->id]], - ]))->toThrow(ValidationException::class); - expect($disabled->fresh()->enabled)->toBeFalse(); -}); - -function legacyAggregatePost(Workspace $workspace, User $user, Status $status, int $targets, array $attributes = []): Post -{ - $post = Post::factory()->create(array_merge([ - 'workspace_id' => $workspace->id, - 'user_id' => $user->id, - 'status' => $status, - 'scheduled_at' => $status === Status::Scheduled ? now()->addDay() : null, - ], $attributes)); - - foreach (SocialAccount::factory()->count($targets)->create(['workspace_id' => $workspace->id]) as $account) { - PostPlatform::factory()->create(['post_id' => $post->id, 'social_account_id' => $account->id]); - } - - return $post; -} - -test('split posts share one group and a re-run changes nothing', function () { - $user = User::factory()->create(); - $workspace = Workspace::factory()->create(['user_id' => $user->id]); - $post = legacyAggregatePost($workspace, $user, Status::Scheduled, 3); - $updatedAt = $post->fresh()->updated_at; - - $this->artisan('posts:split-legacy-active') - ->expectsOutputToContain('Split 1 original posts and created 2 independent posts.') - ->assertSuccessful(); - - $groups = Post::query()->pluck('post_group_id', 'id'); - $groupId = $groups->get($post->id); - - expect($groups)->toHaveCount(3) - ->and($groupId)->not->toBeNull() - ->and($groups->unique()->values()->all())->toBe([$groupId]) - ->and($post->fresh()->updated_at->equalTo($updatedAt))->toBeTrue(); - - $this->artisan('posts:split-legacy-active') - ->expectsOutputToContain('Split 0 original posts and created 0 independent posts.') - ->expectsOutputToContain('Grouped 0 multi-target posts.') - ->assertSuccessful(); - - expect(Post::count())->toBe(3) - ->and(Post::query()->pluck('post_group_id', 'id')->all())->toEqual($groups->all()); -}); - -test('split reuses a group the original already has', function () { - $user = User::factory()->create(); - $workspace = Workspace::factory()->create(['user_id' => $user->id]); - $groupId = '0190a3b2-0000-7000-8000-0000000000aa'; - legacyAggregatePost($workspace, $user, Status::Draft, 2, ['post_group_id' => $groupId]); - - $this->artisan('posts:split-legacy-active')->assertSuccessful(); - - expect(Post::query()->pluck('post_group_id')->unique()->all())->toBe([$groupId]); -}); - -test('unsplit multi-target history gets its own group and single-target posts stay ungrouped', function () { - $user = User::factory()->create(); - $workspace = Workspace::factory()->create(['user_id' => $user->id]); - $published = legacyAggregatePost($workspace, $user, Status::Published, 2); - $failed = legacyAggregatePost($workspace, $user, Status::Failed, 3); - $partial = legacyAggregatePost($workspace, $user, Status::PartiallyPublished, 2); - $single = legacyAggregatePost($workspace, $user, Status::Published, 1); - $otherWorkspace = Workspace::factory()->create(['user_id' => $user->id]); - $alreadyGrouped = legacyAggregatePost($otherWorkspace, $user, Status::Published, 2, ['post_group_id' => '0190a3b2-0000-7000-8000-0000000000bb']); - $otherSingle = legacyAggregatePost($otherWorkspace, $user, Status::Draft, 1); - - $this->artisan('posts:split-legacy-active') - ->expectsOutputToContain('Grouped 3 multi-target posts.') - ->assertSuccessful(); - - $groups = collect([$published, $failed, $partial])->map(fn (Post $post) => $post->fresh()->post_group_id); - - expect($groups->filter()->unique())->toHaveCount(3) - ->and(Post::count())->toBe(6) - ->and($single->fresh()->post_group_id)->toBeNull() - ->and($otherSingle->fresh()->post_group_id)->toBeNull() - ->and($alreadyGrouped->fresh()->post_group_id)->toBe('0190a3b2-0000-7000-8000-0000000000bb'); - - $this->artisan('posts:split-legacy-active') - ->expectsOutputToContain('Grouped 0 multi-target posts.') - ->assertSuccessful(); - - expect(collect([$published, $failed, $partial])->map(fn (Post $post) => $post->fresh()->post_group_id)->all())->toBe($groups->all()); -}); - -/** - * A legacy post on two channels whose one item points at a post-owned row, - * as it is once the library was adopted or the post was edited. - * - * @return array{0: Post, 1: Media} - */ -function splitPostWithOwnedMedia(Workspace $workspace, Status $status = Status::Draft): array -{ - $post = legacyAggregatePost($workspace, $workspace->owner, $status, 2); - $media = Media::factory()->ownedByPost($post)->create(['path' => 'medias/'.Str::uuid().'.jpg']); - Storage::put($media->path, 'owned bytes'); - $post->forceFill(['media' => [[...MediaItem::fromMedia($media)->toArray(), 'meta' => ['alt_text' => 'Kept alt']]]])->saveQuietly(); - - return [$post->fresh(), $media]; -} - -test('split after the library was adopted gives the copy its own media row and file', function () { - Storage::fake(); - $workspace = Workspace::factory()->create(); - $asset = Media::factory()->libraryAsset($workspace)->create(['path' => 'medias/'.Str::uuid().'.jpg']); - Storage::put($asset->path, 'library bytes'); - $post = legacyAggregatePost($workspace, $workspace->owner, Status::Scheduled, 2, [ - 'media' => [['id' => $asset->id, 'path' => $asset->path, 'url' => $asset->url, 'type' => 'image', 'mime_type' => 'image/jpeg', 'meta' => ['alt_text' => 'Kept alt']]], - ]); - AdoptWorkspaceLibrary::execute($workspace); - - $this->artisan('posts:split-legacy-active')->assertSuccessful(); - - $clone = Post::query()->whereKeyNot($post->id)->sole(); - $originalRow = Media::query()->findOrFail(data_get($post->fresh()->media, '0.id')); - $cloneRow = Media::query()->findOrFail(data_get($clone->media, '0.id')); - - expect($originalRow->post_id)->toBe($post->id) - ->and($cloneRow->post_id)->toBe($clone->id) - ->and($cloneRow->path)->not->toBe($originalRow->path) - ->and(data_get($clone->media, '0.path'))->toBe($cloneRow->path) - ->and(data_get($clone->media, '0.meta.alt_text'))->toBe('Kept alt') - ->and(Storage::get($cloneRow->path))->toBe('library bytes') - ->and(collect(AuditMedia::execute())->every(fn (array $findings): bool => $findings === []))->toBeTrue(); - - DeleteOwnedMedia::forPosts([$clone->id]); - - Storage::assertMissing($cloneRow->path); - Storage::assertExists($originalRow->path); -}); - -test('a legacy post edited before the split keeps its media and the copy gets its own', function () { - Storage::fake(); - $workspace = Workspace::factory()->create(); - [$post, $media] = splitPostWithOwnedMedia($workspace); - - $this->artisan('posts:split-legacy-active')->assertSuccessful(); - $this->artisan('posts:split-legacy-active')->assertSuccessful(); - - $clone = Post::query()->whereKeyNot($post->id)->sole(); - $cloneRow = Media::query()->findOrFail(data_get($clone->media, '0.id')); - - expect($post->fresh()->media)->toEqual($post->media) - ->and($media->fresh()->post_id)->toBe($post->id) - ->and($cloneRow->post_id)->toBe($clone->id) - ->and($cloneRow->id)->not->toBe($media->id) - ->and(Storage::get($cloneRow->path))->toBe('owned bytes') - ->and(Media::query()->count())->toBe(2) - ->and(collect(AuditMedia::execute())->every(fn (array $findings): bool => $findings === []))->toBeTrue(); -}); - -test('a copy whose media file is gone is not split and the run goes on', function () { - Storage::fake(); - $workspace = Workspace::factory()->create(); - [$broken, $media] = splitPostWithOwnedMedia($workspace); - Storage::delete($media->path); - [$healthy] = splitPostWithOwnedMedia($workspace); - - $this->artisan('posts:split-legacy-active') - ->expectsOutputToContain("Post {$broken->id} was not split") - ->assertSuccessful(); - - expect($broken->postPlatforms()->count())->toBe(2) - ->and(Post::query()->count())->toBe(3) - ->and($healthy->postPlatforms()->count())->toBe(1); -}); - -test('every item of the original lands on the copy in order, including items without an id and repeated ones', function () { - Storage::fake(); - $workspace = Workspace::factory()->create(); - [$post, $owned] = splitPostWithOwnedMedia($workspace); - $asset = Media::factory()->libraryAsset($workspace)->create(['path' => 'medias/'.Str::uuid().'.jpg']); - $ownedItem = $post->media[0]; - $items = [ - ['url' => 'https://images.example/a.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg'], - ['url' => 'https://images.example/b.jpg', 'type' => 'image', 'mime_type' => 'image/jpeg'], - $ownedItem, - [...$ownedItem, 'meta' => ['alt_text' => 'Second use']], - ['id' => 'legacy-id', 'path' => 'posts/photo.jpg', 'url' => 'https://cdn.example/photo.jpg', 'type' => 'image'], - ['id' => $asset->id, 'path' => $asset->path, 'url' => $asset->url, 'type' => 'image'], - ]; - $post->forceFill(['media' => $items])->saveQuietly(); - - $this->artisan('posts:split-legacy-active')->assertSuccessful(); - - $clone = Post::query()->whereKeyNot($post->id)->sole(); - $copy = Media::query()->where('post_id', $clone->id)->sole(); - $swap = fn (array $item): array => [...$item, 'id' => $copy->id, 'path' => $copy->path, 'url' => $copy->url]; - - expect($post->fresh()->media)->toEqual($items) - ->and($clone->media)->toEqual([$items[0], $items[1], $swap($items[2]), $swap($items[3]), $items[4], $items[5]]) - ->and($owned->fresh()->post_id)->toBe($post->id); -}); - -test('a settled multi-target post becomes one post per network with the status of its own target, moving the targets', function () { - $workspace = Workspace::factory()->create(); - $label = WorkspaceLabel::factory()->create(['workspace_id' => $workspace->id]); - $post = Post::factory()->create([ - 'workspace_id' => $workspace->id, - 'user_id' => $workspace->user_id, - 'status' => Status::PartiallyPublished, - 'content' => 'Settled caption', - 'published_at' => now()->subDay()->startOfSecond(), - 'created_at' => now()->subDays(2)->startOfSecond(), - ]); - $post->labels()->attach($label); - $note = PostNote::factory()->create(['post_id' => $post->id, 'user_id' => $workspace->user_id]); - $youtube = PostPlatform::factory()->youtube()->published()->create([ - 'post_id' => $post->id, - 'social_account_id' => SocialAccount::factory()->create(['workspace_id' => $workspace->id])->id, - 'platform_url' => 'https://youtube.com/watch?v=abc', - ]); - $instagram = PostPlatform::factory()->instagram()->failed()->create([ - 'post_id' => $post->id, - 'social_account_id' => SocialAccount::factory()->create(['workspace_id' => $workspace->id])->id, - 'error_message' => 'Media rejected', - ]); - $disabled = PostPlatform::factory()->disabled()->create(['post_id' => $post->id, 'social_account_id' => SocialAccount::factory()->create(['workspace_id' => $workspace->id])->id]); - $publication = AnalyticsPublication::factory()->create(['workspace_id' => $workspace->id, 'post_platform_id' => $youtube->id]); - Event::fake([PostCreated::class, PostStatusChanged::class]); - - $this->artisan('posts:split-legacy-active')->assertSuccessful(); - $this->artisan('posts:split-legacy-active')->assertSuccessful(); - - $youtubePost = $youtube->fresh()->post; - $instagramPost = $instagram->fresh()->post; - - expect(Post::query()->count())->toBe(2) - ->and(PostPlatform::query()->count())->toBe(3) - ->and($youtubePost->id)->not->toBe($instagramPost->id) - ->and([$youtubePost->id, $instagramPost->id])->toContain($post->id) - ->and($youtubePost->status)->toBe(Status::Published) - ->and($instagramPost->status)->toBe(Status::Failed) - ->and($youtubePost->post_group_id)->not->toBeNull() - ->and($instagramPost->post_group_id)->toBe($youtubePost->post_group_id) - ->and($youtube->fresh()->platform_url)->toBe('https://youtube.com/watch?v=abc') - ->and($instagram->fresh()->error_message)->toBe('Media rejected') - ->and($publication->fresh()->post_platform_id)->toBe($youtube->id) - ->and($disabled->fresh()->post_id)->toBe($post->id) - ->and(Post::query()->where('status', Status::PartiallyPublished)->exists())->toBeFalse(); - - foreach ([$youtubePost, $instagramPost] as $split) { - expect($split->content)->toBe('Settled caption') - ->and($split->user_id)->toBe($post->user_id) - ->and($split->published_at?->toIso8601String())->toBe($split->postPlatforms()->enabled()->sole()->published_at?->toIso8601String()) - ->and($split->created_at->equalTo($post->created_at))->toBeTrue() - ->and($split->labels()->pluck('workspace_labels.id')->all())->toBe([$label->id]) - ->and($split->notes()->pluck('body')->all())->toBe([$note->body]) - ->and($split->postPlatforms()->enabled()->count())->toBe(1); - } - - Event::assertNotDispatched(PostCreated::class); - Event::assertNotDispatched(PostStatusChanged::class); -}); - -test('a partially published post left with one target takes that target status', function () { - $workspace = Workspace::factory()->create(); - $post = Post::factory()->create(['workspace_id' => $workspace->id, 'user_id' => $workspace->user_id, 'status' => Status::PartiallyPublished]); - PostPlatform::factory()->published()->create(['post_id' => $post->id, 'social_account_id' => SocialAccount::factory()->create(['workspace_id' => $workspace->id])->id]); - PostPlatform::factory()->disabled()->create(['post_id' => $post->id, 'social_account_id' => SocialAccount::factory()->create(['workspace_id' => $workspace->id])->id]); - - $this->artisan('posts:split-legacy-active')->assertSuccessful(); - - expect($post->fresh()->status)->toBe(Status::Published) - ->and(Post::query()->count())->toBe(1); -}); - -test('each settled post takes published_at from its own target and keeps the original created_at, scheduled_at and updated_at', function () { - Storage::fake(); - $workspace = Workspace::factory()->create(); - $publishedAt = now()->subDays(20)->startOfSecond(); - $createdAt = now()->subDays(22)->startOfSecond(); - $updatedAt = now()->subDays(19)->startOfSecond(); - $post = Post::factory()->create([ - 'workspace_id' => $workspace->id, - 'user_id' => $workspace->user_id, - 'status' => Status::PartiallyPublished, - 'scheduled_at' => $publishedAt, - 'published_at' => $publishedAt, - ]); - $media = Media::factory()->ownedByPost($post)->create(['path' => 'medias/'.Str::uuid().'.jpg']); - Storage::put($media->path, 'bytes'); - $post->forceFill(['media' => [MediaItem::fromMedia($media)->toArray()]])->saveQuietly(); - Post::query()->whereKey($post->id)->toBase()->update(['created_at' => $createdAt, 'updated_at' => $updatedAt]); - $youtube = PostPlatform::factory()->youtube()->published()->create(['post_id' => $post->id, 'published_at' => $publishedAt, 'social_account_id' => SocialAccount::factory()->create(['workspace_id' => $workspace->id])->id]); - $instagram = PostPlatform::factory()->instagram()->failed()->create(['post_id' => $post->id, 'published_at' => null, 'social_account_id' => SocialAccount::factory()->create(['workspace_id' => $workspace->id])->id]); - - $this->artisan('posts:split-legacy-active')->assertSuccessful(); - - $youtubePost = $youtube->fresh()->post; - $instagramPost = $instagram->fresh()->post; - - expect($youtubePost->published_at->equalTo($publishedAt))->toBeTrue() - ->and($instagramPost->status)->toBe(Status::Failed) - ->and($instagramPost->published_at)->toBeNull(); - - foreach ([$youtubePost, $instagramPost] as $split) { - expect($split->created_at->equalTo($createdAt))->toBeTrue() - ->and($split->scheduled_at->equalTo($publishedAt))->toBeTrue() - ->and($split->updated_at->equalTo($updatedAt))->toBeTrue(); - } -}); diff --git a/tests/Unit/Enums/PostPlatform/StatusTest.php b/tests/Unit/Enums/PostPlatform/StatusTest.php index edae7e8684..f65b8a1c35 100644 --- a/tests/Unit/Enums/PostPlatform/StatusTest.php +++ b/tests/Unit/Enums/PostPlatform/StatusTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use App\Enums\PostPlatform\Status; +use App\Enums\Post\PublishStatus as Status; test('a finished target counts toward settling the parent post', function (Status $status, bool $finished) { expect($status->isFinished())->toBe($finished); diff --git a/tests/Unit/PostPlatformTest.php b/tests/Unit/PostPlatformTest.php index c3a1efb518..1fc2f58910 100644 --- a/tests/Unit/PostPlatformTest.php +++ b/tests/Unit/PostPlatformTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use App\Enums\PostPlatform\Status; +use App\Enums\Post\PublishStatus as Status; use App\Models\Post; use App\Models\PostPlatform; use App\Models\SocialAccount; From c7c6cb1d29600c7a085000cde4c0313fb3ebf262 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 8 Oct 2026 20:06:45 -0300 Subject: [PATCH 02/54] Show one channel per post card and update copy for single-channel posts Post cards, the calendar, post details and the failure popover read the channel and publication from the post itself. Edits report meta errors under meta.*, the partially published status and webhook event are gone, and saving a channel-less draft asks for a channel first. --- lang/ar/posts.php | 14 +-- lang/ar/webhooks.php | 1 - lang/de/posts.php | 14 +-- lang/de/webhooks.php | 1 - lang/el/posts.php | 14 +-- lang/el/webhooks.php | 1 - lang/en/posts.php | 14 +-- lang/en/webhooks.php | 1 - lang/es/posts.php | 14 +-- lang/es/webhooks.php | 1 - lang/fr/posts.php | 14 +-- lang/fr/webhooks.php | 1 - lang/it/posts.php | 14 +-- lang/it/webhooks.php | 1 - lang/ja/posts.php | 14 +-- lang/ja/webhooks.php | 1 - lang/ko/posts.php | 14 +-- lang/ko/webhooks.php | 1 - lang/nl/posts.php | 14 +-- lang/nl/webhooks.php | 1 - lang/pl/posts.php | 14 +-- lang/pl/webhooks.php | 1 - lang/pt-BR/posts.php | 14 +-- lang/pt-BR/webhooks.php | 1 - lang/ru/posts.php | 14 +-- lang/ru/webhooks.php | 1 - lang/tr/posts.php | 14 +-- lang/tr/webhooks.php | 1 - lang/uk/posts.php | 14 +-- lang/uk/webhooks.php | 1 - lang/zh/posts.php | 14 +-- lang/zh/webhooks.php | 1 - .../js/components/ChannelConfigurator.vue | 3 - .../posts/editor/GoogleBusinessSettings.vue | 4 +- .../posts/editor/YouTubeSettings.vue | 4 +- .../components/publish/ApprovePostButton.vue | 4 +- .../components/publish/CalendarPostChip.vue | 37 ++------ .../publish/CalendarUndatedPanel.vue | 6 +- .../components/publish/PostDetailsDialog.vue | 37 ++++---- .../components/publish/PostFailurePopover.vue | 26 +++--- .../components/publish/PostTimelineCard.vue | 85 ++++--------------- .../repurpose/RepurposeItemList.vue | 32 ++----- .../js/components/webhook/webhook-events.ts | 1 - resources/js/composables/usePageErrors.ts | 2 +- .../js/composables/usePostCardActions.ts | 4 +- resources/js/composables/usePostStatus.ts | 16 ---- resources/js/pages/publish/Index.vue | 38 ++------- resources/js/types/analytics.ts | 3 +- resources/js/types/post.ts | 5 +- resources/js/types/publish.ts | 35 ++++---- resources/js/types/repurpose.ts | 10 +-- tests/fixtures/single-line-copy.php | 1 - 52 files changed, 118 insertions(+), 475 deletions(-) diff --git a/lang/ar/posts.php b/lang/ar/posts.php index aa40ee33dd..7aa5a8ceaa 100644 --- a/lang/ar/posts.php +++ b/lang/ar/posts.php @@ -533,7 +533,6 @@ 'publishing' => 'قيد النشر', 'retrying' => 'إعادة المحاولة', 'published' => 'منشور', - 'partially_published' => 'منشور جزئيًا', 'failed' => 'فشل', ], @@ -639,16 +638,6 @@ 'flags' => 'الأعلام', ], - 'status' => [ - 'pending' => 'قيد الانتظار', - 'scheduled' => 'مجدول', - 'published' => 'منشور', - 'publishing' => 'جارٍ النشر...', - 'retrying' => 'جارٍ إعادة المحاولة...', - 'failed' => 'فشل', - 'pending_review' => 'قيد مراجعة Google', - 'rejected' => 'مرفوض', - ], 'delete_modal' => [ 'title' => 'حذف المنشور', @@ -819,12 +808,11 @@ 'media_file_too_large' => 'حجم هذا الملف يتجاوز :size ميغابايت المسموح بها لنوعه.', 'queue_requires_schedule' => 'ليس لهذه القناة أوقات نشر بعد. أضف أوقات النشر من إعدادات القناة لاستخدام قائمة الانتظار.', 'queue_with_scheduled_at' => 'اختر موضعًا في قائمة الانتظار أو تاريخًا ووقتًا، وليس كليهما.', - 'queue_legacy_post' => 'أُنشئ هذا المنشور لعدة قنوات دفعة واحدة ولا يمكن إضافته إلى قائمة الانتظار. حدّد تاريخًا ووقتًا بدلًا من ذلك.', 'queue_busy' => 'يجري تحديث قائمة الانتظار. يرجى المحاولة مرة أخرى.', 'no_social_account' => 'لا يحتوي هذا المنشور على حساب اجتماعي، لذا لا يمكن نشره.', + 'choose_channel' => 'اختر قناة قبل جدولة هذا المنشور أو نشره.', 'queue_order_stale' => 'تغيّرت قائمة الانتظار منذ فتحتها. حدّث الصفحة وحاول مرة أخرى.', 'account_disconnected' => 'الحساب الاجتماعي مفصول', - 'target_disabled' => 'تم إيقاف وجهة النشر هذه', 'account_token_expired' => 'انتهت جلسة الحساب الاجتماعي — يرجى إعادة الربط', 'platform_unavailable' => 'المنصة غير متاحة مؤقتًا. سنعيد المحاولة قريبًا.', 'platform_unavailable_exhausted' => 'ظلت المنصة غير متاحة بعد عدة محاولات. يرجى المحاولة لاحقًا.', diff --git a/lang/ar/webhooks.php b/lang/ar/webhooks.php index caa17aba75..8695382bd2 100644 --- a/lang/ar/webhooks.php +++ b/lang/ar/webhooks.php @@ -96,7 +96,6 @@ 'post_scheduled' => 'عند جدولة منشور للنشر.', 'post_unscheduled' => 'عند إعادة منشور مجدول إلى المسودات.', 'post_published' => 'عند نشر منشور على قناة.', - 'post_partially_published' => 'عند نجاح النشر في بعض القنوات وفشله في أخرى.', 'post_failed' => 'عند فشل نشر منشور.', 'post_deleted' => 'عند حذف منشور.', ], diff --git a/lang/de/posts.php b/lang/de/posts.php index 83d920fd8b..1b9729e8ed 100644 --- a/lang/de/posts.php +++ b/lang/de/posts.php @@ -535,7 +535,6 @@ 'publishing' => 'Wird veröffentlicht', 'retrying' => 'Erneuter Versuch', 'published' => 'Veröffentlicht', - 'partially_published' => 'Teilweise veröffentlicht', 'failed' => 'Fehlgeschlagen', ], @@ -641,16 +640,6 @@ 'flags' => 'Flaggen', ], - 'status' => [ - 'pending' => 'Ausstehend', - 'scheduled' => 'Geplant', - 'published' => 'Veröffentlicht', - 'publishing' => 'Wird veröffentlicht...', - 'retrying' => 'Erneuter Versuch...', - 'failed' => 'Fehlgeschlagen', - 'pending_review' => 'Google-Prüfung läuft', - 'rejected' => 'Abgelehnt', - ], 'delete_modal' => [ 'title' => 'Beitrag löschen', @@ -821,12 +810,11 @@ 'media_file_too_large' => 'Diese Datei ist größer als die für ihren Typ erlaubten :size MB.', 'queue_requires_schedule' => 'Dieser Kanal hat noch keine Veröffentlichungszeiten. Füge in den Kanaleinstellungen Zeiten hinzu, um die Warteschlange zu nutzen.', 'queue_with_scheduled_at' => 'Wähle entweder eine Position in der Warteschlange oder Datum und Uhrzeit, nicht beides.', - 'queue_legacy_post' => 'Dieser Beitrag wurde für mehrere Kanäle gleichzeitig erstellt und kann keiner Warteschlange hinzugefügt werden. Lege stattdessen Datum und Uhrzeit fest.', 'queue_busy' => 'Die Warteschlange wird gerade aktualisiert. Bitte versuche es erneut.', 'no_social_account' => 'Dieser Beitrag hat kein Social-Media-Konto und kann daher nicht veröffentlicht werden.', + 'choose_channel' => 'Wähle einen Kanal, bevor du diesen Beitrag planst oder veröffentlichst.', 'queue_order_stale' => 'Die Warteschlange hat sich geändert, seit du sie geöffnet hast. Aktualisiere die Seite und versuche es erneut.', 'account_disconnected' => 'Social-Media-Konto ist getrennt', - 'target_disabled' => 'Dieses Ziel wurde deaktiviert', 'account_token_expired' => 'Sitzung des Social-Media-Kontos abgelaufen – bitte erneut verbinden', 'platform_unavailable' => 'Die Plattform ist vorübergehend nicht verfügbar. Wir versuchen es in Kürze erneut.', 'platform_unavailable_exhausted' => 'Die Plattform blieb nach mehreren Versuchen nicht verfügbar. Bitte später erneut versuchen.', diff --git a/lang/de/webhooks.php b/lang/de/webhooks.php index 3afbc1e284..e28f490f7b 100644 --- a/lang/de/webhooks.php +++ b/lang/de/webhooks.php @@ -96,7 +96,6 @@ 'post_scheduled' => 'Wenn ein Beitrag zur Veröffentlichung geplant wird.', 'post_unscheduled' => 'Wenn ein geplanter Beitrag zurück zu Entwürfen geht.', 'post_published' => 'Wenn ein Beitrag auf einem Kanal live geht.', - 'post_partially_published' => 'Wenn einige Kanäle veröffentlichen, andere scheitern.', 'post_failed' => 'Wenn ein Beitrag nicht veröffentlicht werden kann.', 'post_deleted' => 'Wenn ein Beitrag gelöscht wird.', ], diff --git a/lang/el/posts.php b/lang/el/posts.php index 2903149aab..e81e664c55 100644 --- a/lang/el/posts.php +++ b/lang/el/posts.php @@ -533,7 +533,6 @@ 'publishing' => 'Δημοσιεύεται', 'retrying' => 'Επανάληψη', 'published' => 'Δημοσιεύτηκε', - 'partially_published' => 'Δημοσιεύτηκε εν μέρει', 'failed' => 'Απέτυχε', ], @@ -639,16 +638,6 @@ 'flags' => 'Σημαίες', ], - 'status' => [ - 'pending' => 'Σε εκκρεμότητα', - 'scheduled' => 'Προγραμματισμένη', - 'published' => 'Δημοσιεύτηκε', - 'publishing' => 'Δημοσίευση...', - 'retrying' => 'Επανάληψη...', - 'failed' => 'Απέτυχε', - 'pending_review' => 'Σε έλεγχο από την Google', - 'rejected' => 'Απορρίφθηκε', - ], 'delete_modal' => [ 'title' => 'Διαγραφή δημοσίευσης', @@ -819,12 +808,11 @@ 'media_file_too_large' => 'Αυτό το αρχείο υπερβαίνει τα :size MB που επιτρέπονται για τον τύπο του.', 'queue_requires_schedule' => 'Αυτό το κανάλι δεν έχει ακόμη ώρες δημοσίευσης. Προσθέστε ώρες στις ρυθμίσεις του καναλιού για να χρησιμοποιήσετε την ουρά.', 'queue_with_scheduled_at' => 'Επιλέξτε είτε θέση στην ουρά είτε ημερομηνία και ώρα, όχι και τα δύο.', - 'queue_legacy_post' => 'Αυτή η ανάρτηση δημιουργήθηκε για πολλά κανάλια ταυτόχρονα και δεν μπορεί να προστεθεί σε ουρά. Ορίστε ημερομηνία και ώρα.', 'queue_busy' => 'Η ουρά ενημερώνεται. Δοκιμάστε ξανά.', 'no_social_account' => 'Αυτή η ανάρτηση δεν έχει λογαριασμό κοινωνικού δικτύου, οπότε δεν μπορεί να δημοσιευτεί.', + 'choose_channel' => 'Επιλέξτε ένα κανάλι πριν προγραμματίσετε ή δημοσιεύσετε αυτή την ανάρτηση.', 'queue_order_stale' => 'Η ουρά άλλαξε από τότε που την ανοίξατε. Ανανεώστε και δοκιμάστε ξανά.', 'account_disconnected' => 'Ο λογαριασμός κοινωνικού δικτύου έχει αποσυνδεθεί', - 'target_disabled' => 'Αυτός ο προορισμός απενεργοποιήθηκε', 'account_token_expired' => 'Η συνεδρία του λογαριασμού κοινωνικού δικτύου έληξε — επανασυνδεθείτε', 'platform_unavailable' => 'Η πλατφόρμα είναι προσωρινά μη διαθέσιμη. Θα δοκιμάσουμε ξανά σύντομα.', 'platform_unavailable_exhausted' => 'Η πλατφόρμα παρέμεινε μη διαθέσιμη μετά από αρκετές προσπάθειες. Δοκιμάστε ξανά αργότερα.', diff --git a/lang/el/webhooks.php b/lang/el/webhooks.php index 8161a611d1..7587498aa6 100644 --- a/lang/el/webhooks.php +++ b/lang/el/webhooks.php @@ -96,7 +96,6 @@ 'post_scheduled' => 'Όταν μια ανάρτηση προγραμματίζεται.', 'post_unscheduled' => 'Όταν μια προγραμματισμένη ανάρτηση γίνεται πρόχειρο.', 'post_published' => 'Όταν μια ανάρτηση δημοσιεύεται σε ένα κανάλι.', - 'post_partially_published' => 'Όταν μόνο ορισμένα κανάλια δημοσιεύουν.', 'post_failed' => 'Όταν μια ανάρτηση αποτυγχάνει να δημοσιευτεί.', 'post_deleted' => 'Όταν διαγράφεται μια ανάρτηση.', ], diff --git a/lang/en/posts.php b/lang/en/posts.php index 198fa3fa63..3a6a590639 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -533,7 +533,6 @@ 'publishing' => 'Publishing', 'retrying' => 'Retrying', 'published' => 'Published', - 'partially_published' => 'Partially Published', 'failed' => 'Failed', ], @@ -639,16 +638,6 @@ 'flags' => 'Flags', ], - 'status' => [ - 'pending' => 'Pending', - 'scheduled' => 'Scheduled', - 'published' => 'Published', - 'publishing' => 'Publishing...', - 'retrying' => 'Retrying...', - 'failed' => 'Failed', - 'pending_review' => 'In review by Google', - 'rejected' => 'Rejected', - ], 'delete_modal' => [ 'title' => 'Delete Post', @@ -819,12 +808,11 @@ 'media_file_too_large' => 'This file is larger than the :size MB allowed for its type.', 'queue_requires_schedule' => 'This channel has no posting times yet. Add posting times in the channel settings to use the queue.', 'queue_with_scheduled_at' => 'Choose either a queue position or a date and time, not both.', - 'queue_legacy_post' => 'This post was created for several channels at once and cannot be added to a queue. Set a date and time instead.', 'queue_busy' => 'The queue is being updated. Please try again.', 'no_social_account' => 'This post has no social account, so it cannot be published.', + 'choose_channel' => 'Choose a channel before scheduling or publishing this post.', 'queue_order_stale' => 'The queue changed since you opened it. Refresh and try again.', 'account_disconnected' => 'Social account is disconnected', - 'target_disabled' => 'This destination was switched off', 'account_token_expired' => 'Social account session expired — please reconnect', 'platform_unavailable' => 'The platform is temporarily unavailable. We\'ll retry shortly.', 'platform_unavailable_exhausted' => 'The platform stayed unavailable after several retries. Please try again later.', diff --git a/lang/en/webhooks.php b/lang/en/webhooks.php index 30a2cf1556..1cd83d8d84 100644 --- a/lang/en/webhooks.php +++ b/lang/en/webhooks.php @@ -96,7 +96,6 @@ 'post_scheduled' => 'When a post is scheduled to publish.', 'post_unscheduled' => 'When a scheduled post goes back to drafts.', 'post_published' => 'When a post goes live on a channel.', - 'post_partially_published' => 'When some channels publish and others fail.', 'post_failed' => 'When a post fails to publish.', 'post_deleted' => 'When a post is deleted.', ], diff --git a/lang/es/posts.php b/lang/es/posts.php index 5f5339afbf..6f6a1c7c62 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -533,7 +533,6 @@ 'publishing' => 'Publicando', 'retrying' => 'Reintentando', 'published' => 'Publicado', - 'partially_published' => 'Parcialmente publicado', 'failed' => 'Fallido', ], @@ -639,16 +638,6 @@ 'flags' => 'Banderas', ], - 'status' => [ - 'pending' => 'Pendiente', - 'scheduled' => 'Programado', - 'published' => 'Publicado', - 'publishing' => 'Publicando...', - 'retrying' => 'Reintentando...', - 'failed' => 'Fallido', - 'pending_review' => 'En revisión por Google', - 'rejected' => 'Rechazado', - ], 'delete_modal' => [ 'title' => 'Eliminar post', @@ -819,12 +808,11 @@ 'media_file_too_large' => 'Este archivo supera los :size MB permitidos para su tipo.', 'queue_requires_schedule' => 'Este canal aún no tiene horarios de publicación. Añade horarios en los ajustes del canal para usar la cola.', 'queue_with_scheduled_at' => 'Elige una posición en la cola o una fecha y hora, no ambas.', - 'queue_legacy_post' => 'Esta publicación se creó para varios canales a la vez y no se puede añadir a una cola. Define una fecha y hora.', 'queue_busy' => 'La cola se está actualizando. Inténtalo de nuevo.', 'no_social_account' => 'Esta publicación no tiene una cuenta social, así que no se puede publicar.', + 'choose_channel' => 'Elige un canal antes de programar o publicar esta publicación.', 'queue_order_stale' => 'La cola ha cambiado desde que la abriste. Actualiza e inténtalo de nuevo.', 'account_disconnected' => 'Cuenta social desconectada', - 'target_disabled' => 'Este destino se desactivó', 'account_token_expired' => 'Sesión de la cuenta social expirada — reconecta la cuenta', 'platform_unavailable' => 'La plataforma no está disponible temporalmente. Reintentaremos en breve.', 'platform_unavailable_exhausted' => 'La plataforma siguió sin estar disponible tras varios reintentos. Inténtalo de nuevo más tarde.', diff --git a/lang/es/webhooks.php b/lang/es/webhooks.php index 1c6763141b..0a49be334f 100644 --- a/lang/es/webhooks.php +++ b/lang/es/webhooks.php @@ -96,7 +96,6 @@ 'post_scheduled' => 'Cuando se programa un post para publicarse.', 'post_unscheduled' => 'Cuando un post programado vuelve a borradores.', 'post_published' => 'Cuando un post se publica en un canal.', - 'post_partially_published' => 'Cuando algunos canales publican y otros fallan.', 'post_failed' => 'Cuando un post no se puede publicar.', 'post_deleted' => 'Cuando se elimina un post.', ], diff --git a/lang/fr/posts.php b/lang/fr/posts.php index f79e4c018a..5d69c2b81a 100644 --- a/lang/fr/posts.php +++ b/lang/fr/posts.php @@ -533,7 +533,6 @@ 'publishing' => 'Publication en cours', 'retrying' => 'Nouvelle tentative', 'published' => 'Publiée', - 'partially_published' => 'Partiellement publiée', 'failed' => 'Échec', ], @@ -639,16 +638,6 @@ 'flags' => 'Drapeaux', ], - 'status' => [ - 'pending' => 'En attente', - 'scheduled' => 'Programmée', - 'published' => 'Publiée', - 'publishing' => 'Publication en cours...', - 'retrying' => 'Nouvelle tentative...', - 'failed' => 'Échec', - 'pending_review' => 'En cours d\'examen par Google', - 'rejected' => 'Refusé', - ], 'delete_modal' => [ 'title' => 'Supprimer la publication', @@ -819,12 +808,11 @@ 'media_file_too_large' => 'Ce fichier dépasse les :size Mo autorisés pour son type.', 'queue_requires_schedule' => 'Ce canal n\'a pas encore d\'horaires de publication. Ajoutez-en dans les paramètres du canal pour utiliser la file d\'attente.', 'queue_with_scheduled_at' => 'Choisissez une position dans la file d\'attente ou une date et une heure, pas les deux.', - 'queue_legacy_post' => 'Ce post a été créé pour plusieurs canaux à la fois et ne peut pas être ajouté à une file d\'attente. Définissez plutôt une date et une heure.', 'queue_busy' => 'La file d\'attente est en cours de mise à jour. Veuillez réessayer.', 'no_social_account' => 'Cette publication n\'a pas de compte social, elle ne peut donc pas être publiée.', + 'choose_channel' => 'Choisissez un canal avant de programmer ou de publier ce post.', 'queue_order_stale' => 'La file d\'attente a changé depuis votre ouverture. Actualisez et réessayez.', 'account_disconnected' => 'Le compte social est déconnecté', - 'target_disabled' => 'Cette destination a été désactivée', 'account_token_expired' => 'La session du compte social a expiré — veuillez reconnecter', 'platform_unavailable' => 'La plateforme est temporairement indisponible. Nouvelle tentative sous peu.', 'platform_unavailable_exhausted' => 'La plateforme est restée indisponible après plusieurs tentatives. Réessayez plus tard.', diff --git a/lang/fr/webhooks.php b/lang/fr/webhooks.php index d1b2b54a33..7d5c9aad85 100644 --- a/lang/fr/webhooks.php +++ b/lang/fr/webhooks.php @@ -96,7 +96,6 @@ 'post_scheduled' => 'Quand une publication est programmée.', 'post_unscheduled' => 'Quand une publication programmée repasse en brouillon.', 'post_published' => 'Quand une publication est en ligne sur un canal.', - 'post_partially_published' => 'Quand certains canaux publient et d’autres échouent.', 'post_failed' => 'Quand une publication échoue.', 'post_deleted' => 'Quand une publication est supprimée.', ], diff --git a/lang/it/posts.php b/lang/it/posts.php index d317676314..f19e010cac 100644 --- a/lang/it/posts.php +++ b/lang/it/posts.php @@ -533,7 +533,6 @@ 'publishing' => 'In pubblicazione', 'retrying' => 'Nuovo tentativo', 'published' => 'Pubblicato', - 'partially_published' => 'Pubblicato parzialmente', 'failed' => 'Non riuscito', ], @@ -639,16 +638,6 @@ 'flags' => 'Bandiere', ], - 'status' => [ - 'pending' => 'In attesa', - 'scheduled' => 'Programmato', - 'published' => 'Pubblicato', - 'publishing' => 'Pubblicazione in corso...', - 'retrying' => 'Nuovo tentativo...', - 'failed' => 'Non riuscito', - 'pending_review' => 'In revisione da Google', - 'rejected' => 'Rifiutato', - ], 'delete_modal' => [ 'title' => 'Elimina post', @@ -819,12 +808,11 @@ 'media_file_too_large' => 'Questo file supera i :size MB consentiti per il suo tipo.', 'queue_requires_schedule' => 'Questo canale non ha ancora orari di pubblicazione. Aggiungili nelle impostazioni del canale per usare la coda.', 'queue_with_scheduled_at' => 'Scegli una posizione nella coda oppure una data e un\'ora, non entrambe.', - 'queue_legacy_post' => 'Questo post è stato creato per più canali insieme e non può essere aggiunto a una coda. Imposta invece una data e un\'ora.', 'queue_busy' => 'La coda è in fase di aggiornamento. Riprova.', 'no_social_account' => 'Questo post non ha un account social, quindi non può essere pubblicato.', + 'choose_channel' => 'Scegli un canale prima di programmare o pubblicare questo post.', 'queue_order_stale' => 'La coda è cambiata da quando l\'hai aperta. Aggiorna e riprova.', 'account_disconnected' => 'L\'account social è scollegato', - 'target_disabled' => 'Questa destinazione è stata disattivata', 'account_token_expired' => 'Sessione dell\'account social scaduta — ricollegalo', 'platform_unavailable' => 'La piattaforma è temporaneamente non disponibile. Riproveremo a breve.', 'platform_unavailable_exhausted' => 'La piattaforma è rimasta non disponibile dopo diversi tentativi. Riprova più tardi.', diff --git a/lang/it/webhooks.php b/lang/it/webhooks.php index 2a0a08625b..456a3ec309 100644 --- a/lang/it/webhooks.php +++ b/lang/it/webhooks.php @@ -96,7 +96,6 @@ 'post_scheduled' => 'Quando un post viene programmato.', 'post_unscheduled' => 'Quando un post programmato torna tra le bozze.', 'post_published' => 'Quando un post va online su un canale.', - 'post_partially_published' => 'Quando alcuni canali pubblicano e altri falliscono.', 'post_failed' => 'Quando la pubblicazione di un post non riesce.', 'post_deleted' => 'Quando un post viene eliminato.', ], diff --git a/lang/ja/posts.php b/lang/ja/posts.php index 6f95404929..80553f1bdb 100644 --- a/lang/ja/posts.php +++ b/lang/ja/posts.php @@ -533,7 +533,6 @@ 'publishing' => '公開中', 'retrying' => '再試行中', 'published' => '公開済み', - 'partially_published' => '一部公開済み', 'failed' => '失敗', ], @@ -639,16 +638,6 @@ 'flags' => '旗', ], - 'status' => [ - 'pending' => '保留中', - 'scheduled' => '予約済み', - 'published' => '公開済み', - 'publishing' => '公開中...', - 'retrying' => '再試行中...', - 'failed' => '失敗', - 'pending_review' => 'Google が審査中', - 'rejected' => '拒否されました', - ], 'delete_modal' => [ 'title' => '投稿を削除', @@ -819,12 +808,11 @@ 'media_file_too_large' => 'このファイルは、その形式で許可されている :size MB を超えています。', 'queue_requires_schedule' => 'このチャンネルにはまだ投稿時間がありません。キューを使うには、チャンネル設定で投稿時間を追加してください。', 'queue_with_scheduled_at' => 'キューの位置か日時のどちらか一方を選んでください。', - 'queue_legacy_post' => 'この投稿は複数のチャンネル向けに一度に作成されたため、キューに追加できません。代わりに日時を設定してください。', 'queue_busy' => 'キューを更新中です。もう一度お試しください。', 'no_social_account' => 'この投稿にはソーシャルアカウントがないため、公開できません。', + 'choose_channel' => 'この投稿を予約または公開する前にチャンネルを選択してください。', 'queue_order_stale' => 'キューを開いてから変更されました。更新してもう一度お試しください。', 'account_disconnected' => 'ソーシャルアカウントの接続が解除されています', - 'target_disabled' => 'この投稿先はオフになりました', 'account_token_expired' => 'ソーシャルアカウントのセッションの有効期限が切れました — 再接続してください', 'platform_unavailable' => 'プラットフォームが一時的に利用できません。まもなく再試行します。', 'platform_unavailable_exhausted' => '何度か再試行しましたがプラットフォームが利用できませんでした。後でもう一度お試しください。', diff --git a/lang/ja/webhooks.php b/lang/ja/webhooks.php index 4e36e94c9a..7ff7d14f2e 100644 --- a/lang/ja/webhooks.php +++ b/lang/ja/webhooks.php @@ -96,7 +96,6 @@ 'post_scheduled' => '投稿の公開が予約されたとき。', 'post_unscheduled' => '予約済みの投稿が下書きに戻ったとき。', 'post_published' => '投稿がチャンネルで公開されたとき。', - 'post_partially_published' => '一部のチャンネルで公開され、他が失敗したとき。', 'post_failed' => '投稿の公開に失敗したとき。', 'post_deleted' => '投稿が削除されたとき。', ], diff --git a/lang/ko/posts.php b/lang/ko/posts.php index 0df056530a..5456f7bda0 100644 --- a/lang/ko/posts.php +++ b/lang/ko/posts.php @@ -533,7 +533,6 @@ 'publishing' => '게시 중', 'retrying' => '재시도 중', 'published' => '게시됨', - 'partially_published' => '부분 게시됨', 'failed' => '실패', ], @@ -639,16 +638,6 @@ 'flags' => '깃발', ], - 'status' => [ - 'pending' => '대기 중', - 'scheduled' => '예약됨', - 'published' => '게시됨', - 'publishing' => '게시 중...', - 'retrying' => '재시도 중...', - 'failed' => '실패', - 'pending_review' => 'Google 검토 중', - 'rejected' => '거부됨', - ], 'delete_modal' => [ 'title' => '게시물 삭제', @@ -819,12 +808,11 @@ 'media_file_too_large' => '이 파일은 해당 형식에 허용된 :size MB를 초과합니다.', 'queue_requires_schedule' => '이 채널에는 아직 게시 시간이 없습니다. 대기열을 사용하려면 채널 설정에서 게시 시간을 추가하세요.', 'queue_with_scheduled_at' => '대기열 위치나 날짜와 시간 중 하나만 선택하세요.', - 'queue_legacy_post' => '이 게시물은 여러 채널용으로 한 번에 만들어져 대기열에 추가할 수 없습니다. 대신 날짜와 시간을 설정하세요.', 'queue_busy' => '대기열을 업데이트하는 중입니다. 다시 시도해 주세요.', 'no_social_account' => '이 게시물에는 소셜 계정이 없어 게시할 수 없습니다.', + 'choose_channel' => '이 게시물을 예약하거나 게시하기 전에 채널을 선택하세요.', 'queue_order_stale' => '대기열을 연 이후 변경되었습니다. 새로 고침한 후 다시 시도해 주세요.', 'account_disconnected' => '소셜 계정 연결이 해제되었습니다', - 'target_disabled' => '이 게시 대상이 꺼졌습니다', 'account_token_expired' => '소셜 계정 세션이 만료되었습니다 — 재연결하세요', 'platform_unavailable' => '플랫폼을 일시적으로 사용할 수 없습니다. 곧 다시 시도합니다.', 'platform_unavailable_exhausted' => '여러 번 재시도했지만 플랫폼을 사용할 수 없었습니다. 나중에 다시 시도하세요.', diff --git a/lang/ko/webhooks.php b/lang/ko/webhooks.php index f9a042906d..67cd2cd232 100644 --- a/lang/ko/webhooks.php +++ b/lang/ko/webhooks.php @@ -96,7 +96,6 @@ 'post_scheduled' => '게시물 게시가 예약될 때.', 'post_unscheduled' => '예약된 게시물이 초안으로 돌아갈 때.', 'post_published' => '게시물이 채널에 게시될 때.', - 'post_partially_published' => '일부 채널은 게시되고 나머지는 실패할 때.', 'post_failed' => '게시물 게시에 실패할 때.', 'post_deleted' => '게시물이 삭제될 때.', ], diff --git a/lang/nl/posts.php b/lang/nl/posts.php index 14a26c8a94..bf557e6d01 100644 --- a/lang/nl/posts.php +++ b/lang/nl/posts.php @@ -533,7 +533,6 @@ 'publishing' => 'Publiceren', 'retrying' => 'Opnieuw proberen', 'published' => 'Gepubliceerd', - 'partially_published' => 'Gedeeltelijk gepubliceerd', 'failed' => 'Mislukt', ], @@ -639,16 +638,6 @@ 'flags' => 'Vlaggen', ], - 'status' => [ - 'pending' => 'In afwachting', - 'scheduled' => 'Gepland', - 'published' => 'Gepubliceerd', - 'publishing' => 'Publiceren...', - 'retrying' => 'Opnieuw proberen...', - 'failed' => 'Mislukt', - 'pending_review' => 'In beoordeling bij Google', - 'rejected' => 'Afgewezen', - ], 'delete_modal' => [ 'title' => 'Post verwijderen', @@ -819,12 +808,11 @@ 'media_file_too_large' => 'Dit bestand is groter dan de :size MB die voor dit type is toegestaan.', 'queue_requires_schedule' => 'Dit kanaal heeft nog geen publicatietijden. Voeg tijden toe in de kanaalinstellingen om de wachtrij te gebruiken.', 'queue_with_scheduled_at' => 'Kies een positie in de wachtrij of een datum en tijd, niet allebei.', - 'queue_legacy_post' => 'Dit bericht is voor meerdere kanalen tegelijk gemaakt en kan niet aan een wachtrij worden toegevoegd. Stel in plaats daarvan een datum en tijd in.', 'queue_busy' => 'De wachtrij wordt bijgewerkt. Probeer het opnieuw.', 'no_social_account' => 'Dit bericht heeft geen social account en kan daarom niet worden gepubliceerd.', + 'choose_channel' => 'Kies een kanaal voordat je dit bericht inplant of publiceert.', 'queue_order_stale' => 'De wachtrij is gewijzigd sinds je hem opende. Vernieuw en probeer het opnieuw.', 'account_disconnected' => 'Social account is losgekoppeld', - 'target_disabled' => 'Deze bestemming is uitgeschakeld', 'account_token_expired' => 'Sessie van social account verlopen — koppel opnieuw', 'platform_unavailable' => 'Het platform is tijdelijk niet beschikbaar. We proberen het zo opnieuw.', 'platform_unavailable_exhausted' => 'Het platform bleef na meerdere pogingen niet beschikbaar. Probeer het later opnieuw.', diff --git a/lang/nl/webhooks.php b/lang/nl/webhooks.php index 9d72b78c62..b7a48786f9 100644 --- a/lang/nl/webhooks.php +++ b/lang/nl/webhooks.php @@ -96,7 +96,6 @@ 'post_scheduled' => 'Wanneer een post wordt ingepland.', 'post_unscheduled' => 'Wanneer een geplande post terug naar concepten gaat.', 'post_published' => 'Wanneer een post live gaat op een kanaal.', - 'post_partially_published' => 'Wanneer sommige kanalen publiceren en andere falen.', 'post_failed' => 'Wanneer het publiceren van een post mislukt.', 'post_deleted' => 'Wanneer een post wordt verwijderd.', ], diff --git a/lang/pl/posts.php b/lang/pl/posts.php index 4e97a465a3..1006e58870 100644 --- a/lang/pl/posts.php +++ b/lang/pl/posts.php @@ -533,7 +533,6 @@ 'publishing' => 'Publikowanie', 'retrying' => 'Ponawianie', 'published' => 'Opublikowany', - 'partially_published' => 'Częściowo opublikowany', 'failed' => 'Nieudany', ], @@ -639,16 +638,6 @@ 'flags' => 'Flagi', ], - 'status' => [ - 'pending' => 'Oczekuje', - 'scheduled' => 'Zaplanowany', - 'published' => 'Opublikowany', - 'publishing' => 'Publikowanie...', - 'retrying' => 'Ponawianie...', - 'failed' => 'Nieudany', - 'pending_review' => 'W weryfikacji przez Google', - 'rejected' => 'Odrzucony', - ], 'delete_modal' => [ 'title' => 'Usuń post', @@ -819,12 +808,11 @@ 'media_file_too_large' => 'Ten plik jest większy niż dozwolone dla jego typu :size MB.', 'queue_requires_schedule' => 'Ten kanał nie ma jeszcze godzin publikacji. Dodaj je w ustawieniach kanału, aby korzystać z kolejki.', 'queue_with_scheduled_at' => 'Wybierz albo pozycję w kolejce, albo datę i godzinę, ale nie oba naraz.', - 'queue_legacy_post' => 'Ten post został utworzony jednocześnie dla kilku kanałów i nie można go dodać do kolejki. Ustaw zamiast tego datę i godzinę.', 'queue_busy' => 'Kolejka jest aktualizowana. Spróbuj ponownie.', 'no_social_account' => 'Ten post nie ma konta społecznościowego, więc nie można go opublikować.', + 'choose_channel' => 'Wybierz kanał przed zaplanowaniem lub opublikowaniem tego posta.', 'queue_order_stale' => 'Kolejka zmieniła się od momentu jej otwarcia. Odśwież stronę i spróbuj ponownie.', 'account_disconnected' => 'Konto społecznościowe jest rozłączone', - 'target_disabled' => 'Ten cel publikacji został wyłączony', 'account_token_expired' => 'Sesja konta społecznościowego wygasła — połącz ponownie', 'platform_unavailable' => 'Platforma jest tymczasowo niedostępna. Spróbujemy ponownie wkrótce.', 'platform_unavailable_exhausted' => 'Platforma pozostała niedostępna po kilku próbach. Spróbuj ponownie później.', diff --git a/lang/pl/webhooks.php b/lang/pl/webhooks.php index f139643fb0..d32f9f7ab2 100644 --- a/lang/pl/webhooks.php +++ b/lang/pl/webhooks.php @@ -96,7 +96,6 @@ 'post_scheduled' => 'Gdy post zostanie zaplanowany do publikacji.', 'post_unscheduled' => 'Gdy zaplanowany post wraca do wersji roboczych.', 'post_published' => 'Gdy post zostanie opublikowany na kanale.', - 'post_partially_published' => 'Gdy część kanałów publikuje, a część zawodzi.', 'post_failed' => 'Gdy publikacja posta się nie powiedzie.', 'post_deleted' => 'Gdy post zostanie usunięty.', ], diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index 7e33d68670..0dec881fd5 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -533,7 +533,6 @@ 'publishing' => 'Publicando', 'retrying' => 'Tentando novamente', 'published' => 'Publicado', - 'partially_published' => 'Parcialmente Publicado', 'failed' => 'Falhou', ], @@ -639,16 +638,6 @@ 'flags' => 'Bandeiras', ], - 'status' => [ - 'pending' => 'Pendente', - 'scheduled' => 'Agendado', - 'published' => 'Publicado', - 'publishing' => 'Publicando...', - 'retrying' => 'Tentando novamente...', - 'failed' => 'Falhou', - 'pending_review' => 'Em revisão pelo Google', - 'rejected' => 'Recusado', - ], 'delete_modal' => [ 'title' => 'Excluir Post', @@ -819,12 +808,11 @@ 'media_file_too_large' => 'Este arquivo é maior que os :size MB permitidos para o tipo dele.', 'queue_requires_schedule' => 'Este canal ainda não tem horários de postagem. Adicione horários nas configurações do canal para usar a fila.', 'queue_with_scheduled_at' => 'Escolha uma posição na fila ou uma data e hora, não os dois.', - 'queue_legacy_post' => 'Este post foi criado para vários canais de uma vez e não pode entrar em uma fila. Defina uma data e hora.', 'queue_busy' => 'A fila está sendo atualizada. Tente novamente.', 'no_social_account' => 'Este post não tem uma conta social, então não pode ser publicado.', + 'choose_channel' => 'Escolha um canal antes de agendar ou publicar este post.', 'queue_order_stale' => 'A fila mudou desde que você abriu. Atualize e tente de novo.', 'account_disconnected' => 'Conta social está desconectada', - 'target_disabled' => 'Este destino foi desligado', 'account_token_expired' => 'Sessão da conta social expirou — reconecte a conta', 'platform_unavailable' => 'A plataforma está temporariamente indisponível. Vamos tentar de novo em breve.', 'platform_unavailable_exhausted' => 'A plataforma continuou indisponível após várias tentativas. Tente de novo mais tarde.', diff --git a/lang/pt-BR/webhooks.php b/lang/pt-BR/webhooks.php index 516405a11a..e8a67001ff 100644 --- a/lang/pt-BR/webhooks.php +++ b/lang/pt-BR/webhooks.php @@ -96,7 +96,6 @@ 'post_scheduled' => 'Quando um post é agendado para publicação.', 'post_unscheduled' => 'Quando um post agendado volta para rascunhos.', 'post_published' => 'Quando um post é publicado em um canal.', - 'post_partially_published' => 'Quando alguns canais publicam e outros falham.', 'post_failed' => 'Quando um post falha ao publicar.', 'post_deleted' => 'Quando um post é excluído.', ], diff --git a/lang/ru/posts.php b/lang/ru/posts.php index 694b1245be..6b446a5cbd 100644 --- a/lang/ru/posts.php +++ b/lang/ru/posts.php @@ -533,7 +533,6 @@ 'publishing' => 'Публикуется', 'retrying' => 'Повторная попытка', 'published' => 'Опубликован', - 'partially_published' => 'Частично опубликован', 'failed' => 'Ошибка', ], @@ -639,16 +638,6 @@ 'flags' => 'Флаги', ], - 'status' => [ - 'pending' => 'В ожидании', - 'scheduled' => 'Запланирован', - 'published' => 'Опубликован', - 'publishing' => 'Публикация...', - 'retrying' => 'Повторная попытка...', - 'failed' => 'Ошибка', - 'pending_review' => 'На проверке в Google', - 'rejected' => 'Отклонено', - ], 'delete_modal' => [ 'title' => 'Удалить пост', @@ -819,12 +808,11 @@ 'media_file_too_large' => 'Этот файл больше допустимых для его типа :size МБ.', 'queue_requires_schedule' => 'У этого канала пока нет времени публикации. Добавьте время в настройках канала, чтобы использовать очередь.', 'queue_with_scheduled_at' => 'Выберите либо позицию в очереди, либо дату и время, но не оба варианта.', - 'queue_legacy_post' => 'Этот пост создан сразу для нескольких каналов и не может быть добавлен в очередь. Укажите дату и время.', 'queue_busy' => 'Очередь обновляется. Повторите попытку.', 'no_social_account' => 'У этой публикации нет аккаунта соцсети, поэтому её нельзя опубликовать.', + 'choose_channel' => 'Выберите канал, прежде чем планировать или публиковать этот пост.', 'queue_order_stale' => 'Очередь изменилась с момента открытия. Обновите страницу и повторите попытку.', 'account_disconnected' => 'Социальный аккаунт отключён', - 'target_disabled' => 'Этот канал публикации отключён', 'account_token_expired' => 'Сессия социального аккаунта истекла — переподключите', 'platform_unavailable' => 'Платформа временно недоступна. Мы повторим попытку вскоре.', 'platform_unavailable_exhausted' => 'Платформа оставалась недоступной после нескольких попыток. Попробуйте позже.', diff --git a/lang/ru/webhooks.php b/lang/ru/webhooks.php index 26e55594b8..9520180e52 100644 --- a/lang/ru/webhooks.php +++ b/lang/ru/webhooks.php @@ -96,7 +96,6 @@ 'post_scheduled' => 'Когда пост запланирован к публикации.', 'post_unscheduled' => 'Когда пост снят с расписания.', 'post_published' => 'Когда пост публикуется в канале.', - 'post_partially_published' => 'Когда одни каналы публикуют, а другие нет.', 'post_failed' => 'Когда пост не удаётся опубликовать.', 'post_deleted' => 'Когда пост удалён.', ], diff --git a/lang/tr/posts.php b/lang/tr/posts.php index fd34b50c6b..5b4226648a 100644 --- a/lang/tr/posts.php +++ b/lang/tr/posts.php @@ -535,7 +535,6 @@ 'publishing' => 'Yayınlanıyor', 'retrying' => 'Yeniden deneniyor', 'published' => 'Yayınlandı', - 'partially_published' => 'Kısmen Yayınlandı', 'failed' => 'Başarısız', ], @@ -641,16 +640,6 @@ 'flags' => 'Bayraklar', ], - 'status' => [ - 'pending' => 'Beklemede', - 'scheduled' => 'Zamanlandı', - 'published' => 'Yayınlandı', - 'publishing' => 'Yayınlanıyor...', - 'retrying' => 'Yeniden deneniyor...', - 'failed' => 'Başarısız', - 'pending_review' => 'Google incelemesinde', - 'rejected' => 'Reddedildi', - ], 'delete_modal' => [ 'title' => 'Gönderiyi Sil', @@ -821,12 +810,11 @@ 'media_file_too_large' => 'Bu dosya, türü için izin verilen :size MB sınırını aşıyor.', 'queue_requires_schedule' => 'Bu kanalın henüz paylaşım saati yok. Kuyruğu kullanmak için kanal ayarlarından paylaşım saatleri ekleyin.', 'queue_with_scheduled_at' => 'Kuyruk konumu ya da tarih ve saat seçin, ikisini birden değil.', - 'queue_legacy_post' => 'Bu gönderi aynı anda birden fazla kanal için oluşturuldu ve kuyruğa eklenemez. Bunun yerine tarih ve saat belirleyin.', 'queue_busy' => 'Kuyruk güncelleniyor. Lütfen tekrar deneyin.', 'no_social_account' => 'Bu gönderinin bir sosyal hesabı yok, bu yüzden yayınlanamaz.', + 'choose_channel' => 'Bu gönderiyi planlamadan veya yayınlamadan önce bir kanal seçin.', 'queue_order_stale' => 'Kuyruk siz açtıktan sonra değişti. Yenileyip tekrar deneyin.', 'account_disconnected' => 'Sosyal hesabın bağlantısı kesildi', - 'target_disabled' => 'Bu hedef kapatıldı', 'account_token_expired' => 'Sosyal hesap oturumunun süresi doldu — lütfen yeniden bağlanın', 'platform_unavailable' => 'Platform geçici olarak kullanılamıyor. Kısa süre içinde yeniden deneyeceğiz.', 'platform_unavailable_exhausted' => 'Platform birkaç denemeden sonra kullanılamaz kaldı. Lütfen daha sonra tekrar deneyin.', diff --git a/lang/tr/webhooks.php b/lang/tr/webhooks.php index 93b31db4ee..1ee9a67198 100644 --- a/lang/tr/webhooks.php +++ b/lang/tr/webhooks.php @@ -96,7 +96,6 @@ 'post_scheduled' => 'Bir gönderi yayın için zamanlandığında.', 'post_unscheduled' => 'Zamanlanmış bir gönderi taslaklara döndüğünde.', 'post_published' => 'Bir gönderi bir kanalda yayınlandığında.', - 'post_partially_published' => 'Bazı kanallar yayınlayıp diğerleri başarısız olduğunda.', 'post_failed' => 'Bir gönderi yayınlanamadığında.', 'post_deleted' => 'Bir gönderi silindiğinde.', ], diff --git a/lang/uk/posts.php b/lang/uk/posts.php index bad7442df3..0a2502f985 100644 --- a/lang/uk/posts.php +++ b/lang/uk/posts.php @@ -533,7 +533,6 @@ 'publishing' => 'Публікується', 'retrying' => 'Повторна спроба', 'published' => 'Опубліковано', - 'partially_published' => 'Частково опубліковано', 'failed' => 'Помилка', ], @@ -639,16 +638,6 @@ 'flags' => 'Прапори', ], - 'status' => [ - 'pending' => 'Очікує', - 'scheduled' => 'Заплановано', - 'published' => 'Опубліковано', - 'publishing' => 'Публікується...', - 'retrying' => 'Повторна спроба...', - 'failed' => 'Помилка', - 'pending_review' => 'На перевірці в Google', - 'rejected' => 'Відхилено', - ], 'delete_modal' => [ 'title' => 'Видалити пост', @@ -819,12 +808,11 @@ 'media_file_too_large' => 'Цей файл більший за дозволені для його типу :size МБ.', 'queue_requires_schedule' => 'Цей канал ще не має часу публікації. Додайте час у налаштуваннях каналу, щоб користуватися чергою.', 'queue_with_scheduled_at' => 'Виберіть або позицію в черзі, або дату й час, але не обидва варіанти.', - 'queue_legacy_post' => 'Цей допис створено одразу для кількох каналів, тому його не можна додати до черги. Натомість вкажіть дату й час.', 'queue_busy' => 'Черга оновлюється. Спробуйте ще раз.', 'no_social_account' => 'Ця публікація не має акаунта соцмережі, тому її не можна опублікувати.', + 'choose_channel' => 'Виберіть канал, перш ніж планувати або публікувати цей допис.', 'queue_order_stale' => 'Черга змінилася з моменту відкриття. Оновіть сторінку та спробуйте ще раз.', 'account_disconnected' => 'Соціальний акаунт відключено', - 'target_disabled' => 'Цей канал публікації вимкнено', 'account_token_expired' => 'Сесія соціального акаунта закінчилася — перепідключіть', 'platform_unavailable' => 'Платформа тимчасово недоступна. Ми спробуємо знову незабаром.', 'platform_unavailable_exhausted' => 'Платформа залишалася недоступною після кількох спроб. Спробуйте пізніше.', diff --git a/lang/uk/webhooks.php b/lang/uk/webhooks.php index 7fe4408490..5538af5a4e 100644 --- a/lang/uk/webhooks.php +++ b/lang/uk/webhooks.php @@ -96,7 +96,6 @@ 'post_scheduled' => 'Коли пост заплановано до публікації.', 'post_unscheduled' => 'Коли запланований пост повертається до чернеток.', 'post_published' => 'Коли пост опубліковано в каналі.', - 'post_partially_published' => 'Коли одні канали публікують, а інші ні.', 'post_failed' => 'Коли пост не вдалося опублікувати.', 'post_deleted' => 'Коли пост видалено.', ], diff --git a/lang/zh/posts.php b/lang/zh/posts.php index 3f037c285f..abd2c6e981 100644 --- a/lang/zh/posts.php +++ b/lang/zh/posts.php @@ -533,7 +533,6 @@ 'publishing' => '发布中', 'retrying' => '重试中', 'published' => '已发布', - 'partially_published' => '部分发布', 'failed' => '已失败', ], @@ -639,16 +638,6 @@ 'flags' => '旗帜', ], - 'status' => [ - 'pending' => '等待中', - 'scheduled' => '已排期', - 'published' => '已发布', - 'publishing' => '发布中…', - 'retrying' => '重试中…', - 'failed' => '已失败', - 'pending_review' => 'Google 审核中', - 'rejected' => '已拒绝', - ], 'delete_modal' => [ 'title' => '删除帖子', @@ -819,12 +808,11 @@ 'media_file_too_large' => '此文件超过其类型允许的 :size MB。', 'queue_requires_schedule' => '此频道尚未设置发布时间。请在频道设置中添加发布时间以使用队列。', 'queue_with_scheduled_at' => '请选择队列位置或日期时间,不能同时选择。', - 'queue_legacy_post' => '此帖子是同时为多个频道创建的,无法加入队列。请改为设置日期和时间。', 'queue_busy' => '队列正在更新,请重试。', 'no_social_account' => '此帖子没有社交账号,因此无法发布。', + 'choose_channel' => '请先选择一个频道,再安排或发布此帖子。', 'queue_order_stale' => '自你打开以来队列已发生变化。请刷新后重试。', 'account_disconnected' => '社交账号已断开连接', - 'target_disabled' => '已关闭此发布目标', 'account_token_expired' => '社交账号会话已过期——请重新连接', 'platform_unavailable' => '平台暂时不可用。我们稍后会重试。', 'platform_unavailable_exhausted' => '多次重试后平台仍不可用。请稍后再试。', diff --git a/lang/zh/webhooks.php b/lang/zh/webhooks.php index 796ca61417..176f305c8d 100644 --- a/lang/zh/webhooks.php +++ b/lang/zh/webhooks.php @@ -96,7 +96,6 @@ 'post_scheduled' => '帖子被排期发布时。', 'post_unscheduled' => '已排期的帖子退回草稿时。', 'post_published' => '帖子在频道上发布时。', - 'post_partially_published' => '部分频道发布成功、其他失败时。', 'post_failed' => '帖子发布失败时。', 'post_deleted' => '帖子被删除时。', ], diff --git a/resources/js/components/ChannelConfigurator.vue b/resources/js/components/ChannelConfigurator.vue index 615a464362..1993340873 100644 --- a/resources/js/components/ChannelConfigurator.vue +++ b/resources/js/components/ChannelConfigurator.vue @@ -45,9 +45,6 @@ const emit = defineEmits<{ const isSelected = (id: string): boolean => props.selectedIds.includes(id); -// Order matches the `platforms` array the editor submits (both filter the same -// post_platforms list by the same selection), so a settings panel's position -// here is the `platforms.{index}.*` index its backend errors are keyed by. const selectedChannels = computed(() => props.channels.filter((channel) => isSelected(channel.id)), ); diff --git a/resources/js/components/posts/editor/GoogleBusinessSettings.vue b/resources/js/components/posts/editor/GoogleBusinessSettings.vue index 5d11ef391c..72be4cdc75 100644 --- a/resources/js/components/posts/editor/GoogleBusinessSettings.vue +++ b/resources/js/components/posts/editor/GoogleBusinessSettings.vue @@ -27,7 +27,7 @@ import { } from '@/lib/googleBusiness'; interface Props { - /** This panel's position in the submitted `destinations` (composer) or `platforms` array — see findError. */ + /** This panel's position in the submitted `destinations` (composer); an edit has one and reports `meta.*` — see findError. */ platformIndex: number; meta: Record; disabled?: boolean; @@ -197,7 +197,7 @@ const findError = (field: string) => computed( () => errors.value[`destinations.${props.platformIndex}.meta.${field}`] ?? - errors.value[`platforms.${props.platformIndex}.meta.${field}`], + errors.value[`meta.${field}`], ); const eventTitleError = findError('event.title'); const eventStartDateError = findError('event.start_date'); diff --git a/resources/js/components/posts/editor/YouTubeSettings.vue b/resources/js/components/posts/editor/YouTubeSettings.vue index 4feff471fd..e9cdcdd43a 100644 --- a/resources/js/components/posts/editor/YouTubeSettings.vue +++ b/resources/js/components/posts/editor/YouTubeSettings.vue @@ -156,7 +156,7 @@ const labelFor = ( const titleError = computed( () => errors.value[`destinations.${props.platformIndex}.meta.title`] ?? - errors.value[`platforms.${props.platformIndex}.meta.title`], + errors.value['meta.title'], ); const description = computed({ @@ -170,7 +170,7 @@ const descriptionIssueKey = computed(() => const descriptionServerError = computed( () => errors.value[`destinations.${props.platformIndex}.meta.description`] ?? - errors.value[`platforms.${props.platformIndex}.meta.description`], + errors.value['meta.description'], ); const hasDescriptionError = computed( () => !!descriptionIssueKey.value || !!descriptionServerError.value, diff --git a/resources/js/components/publish/ApprovePostButton.vue b/resources/js/components/publish/ApprovePostButton.vue index 95d3d5bf21..030e84170c 100644 --- a/resources/js/components/publish/ApprovePostButton.vue +++ b/resources/js/components/publish/ApprovePostButton.vue @@ -34,9 +34,7 @@ const isQueued = computed( ); const postTimezone = useComposerTimezone(() => - props.post.post_platforms - .filter((platform) => platform.enabled) - .map((platform) => platform.social_account ?? {}), + props.post.social_account ? [props.post.social_account] : [], ); const needsTime = computed( diff --git a/resources/js/components/publish/CalendarPostChip.vue b/resources/js/components/publish/CalendarPostChip.vue index 8db80b003e..a99734a936 100644 --- a/resources/js/components/publish/CalendarPostChip.vue +++ b/resources/js/components/publish/CalendarPostChip.vue @@ -31,7 +31,6 @@ const props = withDefaults( { layout: 'month' }, ); -const MAX_ICONS = 4; const open = ref(false); const detailsOpen = ref(false); @@ -65,13 +64,11 @@ const onDetails = (value: boolean): void => { const SENT_STATUSES: readonly string[] = [ PostStatus.Published, - PostStatus.PartiallyPublished, PostStatus.Failed, ]; const STATUS_CLASSES: Record = { draft: 'border-dashed', - partially_published: 'border-warning/40 bg-warning/5', failed: 'border-destructive/40 bg-destructive/5', }; @@ -89,27 +86,11 @@ const tab = computed((): PublishTab => { : 'queue'; }); -const visibleTargets = computed(() => - props.post.post_platforms.slice( - 0, - props.post.post_platforms.length > MAX_ICONS - ? MAX_ICONS - 1 - : MAX_ICONS, - ), -); - -const hiddenTargets = computed( - () => props.post.post_platforms.length - visibleTargets.value.length, -); - const channels = computed(() => - props.post.post_platforms - .map( - (target) => - target.social_account?.display_label ?? - getPlatformLabel(target.platform), - ) - .join(', '), + props.post.platform + ? (props.post.social_account?.display_label ?? + getPlatformLabel(props.post.platform)) + : '', ); const time = computed(() => @@ -166,16 +147,10 @@ const scheduleMode = computed(() => - +{{ hiddenTargets }} {{ time }} const plainText = (draft: UndatedDraft): string => htmlToPlainText(draft.content ?? '').trim(); -const target = (draft: UndatedDraft) => draft.post_platforms[0] ?? null; +const target = (draft: UndatedDraft) => (draft.platform ? draft : null);