diff --git a/.ai/rules/google-business.md b/.ai/rules/google-business.md
index ba23713b9..91e80a812 100644
--- a/.ai/rules/google-business.md
+++ b/.ai/rules/google-business.md
@@ -26,7 +26,7 @@ ReconcileGoogleBusinessPost must call ConnectionVerifier::verify() after a Token
Publish and verify require both location_id (v4) and location_name (v1) via GoogleBusinessResourceName::connectedLocation().
## Local Posts 401 after a live BI verify does not expire the account
-retryAfterExpiredToken calls ConnectionVerifier::verify() then retries fetchRemote. markAsTokenExpired only when verify() itself throws TokenExpiredException. If BI verify succeeds and Local Posts still 401s, deferOrGiveUp without disconnecting — the token still works for the house verify. ReconcileGoogleBusinessPosts only dispatches enabled() pending_review rows. ReconcileGoogleBusinessPost::handle() giveUps immediately when socialAccount is null — disconnect nulls the FK and fetchRemote would TypeError until the 24h ceiling.
+retryAfterExpiredToken calls ConnectionVerifier::verify() then retries fetchRemote. markAsTokenExpired only when verify() itself throws TokenExpiredException. If BI verify succeeds and Local Posts still 401s, deferOrGiveUp without disconnecting — the token still works for the house verify. ReconcileGoogleBusinessPosts only dispatches Google Business posts in pending_review with a platform_post_id. ReconcileGoogleBusinessPost::handle() giveUps immediately when socialAccount is null — disconnect nulls the FK and fetchRemote would TypeError until the 24h ceiling.
## Pre-2.0 scheduled targets skip the publish-time media recheck
-post_platforms.scheduled_before_media_checks is set only by migration 2026_10_06_112101 on targets that were pending/publishing/retrying on a scheduled, pending approval or publishing post at deploy. failForInvalidMedia() returns early for them so they publish what main published (main had no publish-time media check; publishers truncate to first/take(n) and networks accept wider video ratios). Never set it from app code; new posts, drafts and failed targets always get the 2.0 recheck. Remove the column once no marked unpublished target is left.
+posts.scheduled_before_media_checks (copied from post_platforms when posts and their destinations were merged) is set only by migration 2026_10_06_112101 on targets that were pending/publishing/retrying on a scheduled, pending approval or publishing post at deploy. failForInvalidMedia() returns early for them so they publish what main published (main had no publish-time media check; publishers truncate to first/take(n) and networks accept wider video ratios). Never set it from app code; new posts, drafts and failed targets always get the 2.0 recheck. Remove the column once no marked unpublished target is left.
diff --git a/.ai/rules/index.md b/.ai/rules/index.md
index 718a3634c..cfa50321f 100644
--- a/.ai/rules/index.md
+++ b/.ai/rules/index.md
@@ -15,11 +15,11 @@ Before planning or editing, find the row whose globs match the file's path and r
| resources/js/** | .ai/rules/js.md |
| app/Actions/Media/**, app/Models/Media.php | .ai/rules/media.md |
| app/Http/Middleware/App/HandleInertiaRequests.php | .ai/rules/middleware-app.md |
-| app/Jobs/PostHog/**, app/Models/PostPlatform.php, database/migrations/**, app/Models/SocialAccount.php | .ai/rules/migrations.md |
+| app/Jobs/PostHog/**, app/Models/Post.php, database/migrations/**, app/Models/SocialAccount.php | .ai/rules/migrations.md |
| app/Jobs/PostHog/** | .ai/rules/post-hog.md |
| app/Enums/PostPlatform/ContentType.php | .ai/rules/post-platform.md |
| app/Actions/Post/FinalizePostPublication.php, app/Jobs/PublishPost.php, app/Actions/Post/UpdatePost.php, app/Actions/Post/** | .ai/rules/post.md |
| app/Enums/SocialAccount/Platform.php | .ai/rules/social-account.md |
-| app/Services/Social/GoogleBusinessPublisher.php, app/Support/Social/GoogleBusinessDerivativeCleaner.php, app/Actions/Post/DeletePost.php, app/Actions/Post/UpdatePost.php, app/Support/Social/AbandonGoogleBusinessReview.php, app/Actions/Workspace/PurgeWorkspace.php, app/Http/Controllers/Auth/SocialController.php, app/Support/Social/ThreadProgress.php, app/Support/ThreadReplies.php, app/Services/Social/Concerns/PublishesThreads.php, app/Services/Social/FacebookPublisher.php, app/Services/Social/InstagramPublisher.php | .ai/rules/social.md |
+| app/Services/Social/GoogleBusinessPublisher.php, app/Support/Social/GoogleBusinessDerivativeCleaner.php, app/Actions/Post/DeletePost.php, app/Actions/Post/UpdatePost.php, app/Actions/Workspace/PurgeWorkspace.php, app/Http/Controllers/Auth/SocialController.php, app/Support/Social/ThreadProgress.php, app/Support/ThreadReplies.php, app/Services/Social/Concerns/PublishesThreads.php, app/Services/Social/FacebookPublisher.php, app/Services/Social/InstagramPublisher.php | .ai/rules/social.md |
| app/Support/PostPlatformMetaRules.php | .ai/rules/support.md |
| app/Mcp/Tools/** | .ai/rules/tools.md |
diff --git a/.ai/rules/jobs.md b/.ai/rules/jobs.md
index faada987e..32a92fc54 100644
--- a/.ai/rules/jobs.md
+++ b/.ai/rules/jobs.md
@@ -8,7 +8,7 @@ paths:
# Jobs
## GBP reconcile must settle permanent API errors
-ReconcileGoogleBusinessPost must not rethrow GoogleBusinessPublishException. Permanent categories (NOT_FOUND, PERMISSION_DENIED, INVALID_ARGUMENT, Unknown) reject the target immediately and call FinalizePostPublication. Only ServerError, RateLimit, and ConnectionException defer until REVIEW_CEILING_HOURS. Throwing leaves last_reconciled_at stale, the 5-minute sweep re-dispatches, RecoverStuckPosts treats PendingReview as still-active, and the post sticks forever. failed() is the safety net if the worker dies mid-review — it must deferOrGiveUp (respect the 24h ceiling), never giveUp immediately. Job $timeout must exceed HasSocialHttpClient's 120s HTTP timeout. RecoverStuckPosts may only fail PendingReview after the same 24h ceiling timed from submitted_at — never created_at (a scheduled draft can be days old before it enters review) and never the 1h publishing timeout. Reconcile must refresh-and-retry on TokenExpiredException the same way PublishToSocialPlatform does; if verify() itself dies, mark the social account token-expired before deferring. If Business Information verify succeeds and Local Posts still 401s, defer without markAsTokenExpired — the token is still valid for the house verify. A 401 must not sit in review for 24h while a refresh would have settled it. JPEG derivatives stay on disk while LocalPostState is Processing/Scheduled so Google can fetch sourceUrl; settle(), RecoverStuckPosts' 1h publishing timeout, a disabled GBP target, and the expired-review path prune them. Reconcile must giveUp immediately when socialAccount is null (disconnect nulls the FK and would TypeError in fetchRemote). When RecoverStuckPosts finishes a post (no still-active targets), call FinalizePostPublication so the owner is notified — do not mark the post Failed/Published by hand. Never compare raw PROCESSING/SCHEDULED/LIVE strings — use App\Enums\GoogleBusiness\LocalPostState.
+ReconcileGoogleBusinessPost must not rethrow GoogleBusinessPublishException. Permanent categories (NOT_FOUND, PERMISSION_DENIED, INVALID_ARGUMENT, Unknown) reject the target immediately and call FinalizePostPublication. Only ServerError, RateLimit, and ConnectionException defer until REVIEW_CEILING_HOURS. Throwing leaves last_reconciled_at stale, the 5-minute sweep re-dispatches, RecoverStuckPosts treats PendingReview as still-active, and the post sticks forever. failed() is the safety net if the worker dies mid-review — it must deferOrGiveUp (respect the 24h ceiling), never giveUp immediately. Job $timeout must exceed HasSocialHttpClient's 120s HTTP timeout. RecoverStuckPosts may only fail PendingReview after the same 24h ceiling timed from submitted_at — never created_at (a scheduled draft can be days old before it enters review) and never the 1h publishing timeout. Reconcile must refresh-and-retry on TokenExpiredException the same way PublishToSocialPlatform does; if verify() itself dies, mark the social account token-expired before deferring. If Business Information verify succeeds and Local Posts still 401s, defer without markAsTokenExpired — the token is still valid for the house verify. A 401 must not sit in review for 24h while a refresh would have settled it. JPEG derivatives stay on disk while LocalPostState is Processing/Scheduled so Google can fetch sourceUrl; settle(), RecoverStuckPosts' 1h publishing timeout and the expired-review path prune them. Reconcile must giveUp immediately when socialAccount is null (disconnect nulls the FK and would TypeError in fetchRemote). When RecoverStuckPosts settles a post's publication, call FinalizePostPublication so the owner is notified — do not mark the post Failed/Published by hand. Never compare raw PROCESSING/SCHEDULED/LIVE strings — use App\Enums\GoogleBusiness\LocalPostState.
## Notifications are email only
There is no in-app notification system: the `notifications` table, bell, broadcast event, presence heartbeat and WorkspaceUserChannel were removed in September 2026. SendNotification(user, type, mailable) only sends the Mailable, gated by User::wantsEmailFor(). User uses RoutesNotifications, not Notifiable (no database channel). A new post note (not an edit, delete or reaction) emails every workspace member except its author (NotifyPostNoteAdded → PostNoteAdded, Type::PostNoteAdded); there are no @mentions in notes. Do not reintroduce an in-app channel.
diff --git a/.ai/rules/media.md b/.ai/rules/media.md
index 2af2ccb34..60322a441 100644
--- a/.ai/rules/media.md
+++ b/.ai/rules/media.md
@@ -7,10 +7,10 @@ paths:
# Media
## Media rows have one owner; write via SyncOwnedMedia, delete via DeleteOwnedMedia
-Every media row has exactly one owner (post_id, idea_id, rss_feed_item_id, a workspace temporary upload, or a mediable logo/avatar); the Media saving guard enforces it and owner FKs are restrictOnDelete, so never cascade media. posts.media / ideas.media are written only by SyncOwnedMedia (inside MediaCopyBatch::run) and by AdoptWorkspaceLibrary::adopt (the one-off library migration). Rows are deleted through DeleteOwnedMedia (locked in id order, files removed after commit by DeleteOrphanedMediaFiles); the only direct deleters are PurgeUserAccess (user avatars, files after commit) and HasMedia::clearMediaCollection (logo/avatar replacement). A temporary upload is adopted (moved) once; every later use copies it. The same holds for rows of an owner the batch deletes before commit (`MediaCopyBatch::releaseOwner`, used by RecoverEmptyDraft). The library collection ('assets') is read only by the adoption (media:adopt-library / release:trypost-2) — no code may write it; the fold_legacy_workspace_media_into_library migration moved the legacy 'ai-generated' workspace rows into it once.
+Every media row has exactly one owner (post_id, idea_id, rss_feed_item_id, a workspace temporary upload, or a mediable logo/avatar); the Media saving guard enforces it and owner FKs are restrictOnDelete, so never cascade media. posts.media / ideas.media are written only by SyncOwnedMedia (inside MediaCopyBatch::run) and by AdoptWorkspaceLibrary::adopt (the one-off library migration). Rows are deleted through DeleteOwnedMedia (locked in id order, files removed after commit by DeleteOrphanedMediaFiles); the only direct deleters are PurgeUserAccess (user avatars, files after commit) and HasMedia::clearMediaCollection (logo/avatar replacement). A temporary upload is adopted (moved) once; every later use copies it. The same holds for rows of an owner the batch deletes before commit (`MediaCopyBatch::releaseOwner`, used by RecoverEmptyDraft). The library collection ('assets') is read only by the adoption (media:adopt-library) — no code may write it; the fold_legacy_workspace_media_into_library migration moved the legacy 'ai-generated' workspace rows into it once.
## A save never resolves another workspace's media
Every save resolves media ids and upload tokens within the owner's workspace (ResolveWorkspaceMedia); only the owner's own stored items and same-workspace duplicate/recovery items pass through without a row. media:adopt-library relies on this: it computes the library rows other workspaces reference once per adoption window and trusts that map in the locked delete, which is only safe if no save can create a new cross-workspace reference.
## Thread reply media is owned by the post; write target meta before SyncOwnedMedia
-Media in meta.thread_replies[*].media are medias rows owned by the post (post_id), like posts.media. SyncOwnedMedia::execute reads the post's stored thread replies, resolves/adopts/copies their items, writes the canonical items back to the PostPlatform meta, and keeps those rows when deleting the post's unused rows. So any caller that changes a target's meta must save it BEFORE calling SyncOwnedMedia (UpdatePost::updateChannelPost does), or new reply uploads are never adopted and old reply rows are not released.
+Media in meta.thread_replies[*].media are medias rows owned by the post (post_id), like posts.media. SyncOwnedMedia::execute reads the post's stored thread replies, resolves/adopts/copies their items, writes the canonical items back to the post's meta, and keeps those rows when deleting the post's unused rows. So any caller that changes the post's meta must save it BEFORE calling SyncOwnedMedia (UpdatePost::updateChannelPost does), or new reply uploads are never adopted and old reply rows are not released.
diff --git a/.ai/rules/migrations.md b/.ai/rules/migrations.md
index 5f4d3bbc5..607a168fd 100644
--- a/.ai/rules/migrations.md
+++ b/.ai/rules/migrations.md
@@ -1,7 +1,7 @@
---
paths:
- 'app/Jobs/PostHog/**'
- - app/Models/PostPlatform.php
+ - app/Models/Post.php
- 'database/migrations/**'
- app/Models/SocialAccount.php
---
@@ -9,7 +9,7 @@ paths:
# Migrations
## Keep publishing activity lookup scoped and minimal
-Account publishing activity must scope through indexed account workspace/post IDs before ordering PostPlatform rows. Its migration only adds conventional Laravel indexes and must not change PostPlatform.published_at precision. PostHog snapshot retries must keep progressive backoff so transient outages do not exhaust attempts immediately.
+Account publishing activity must scope through the account's workspaces (index posts(workspace_id, publish_status, published_at)) before ordering posts. Its migration only adds conventional Laravel indexes and must not change posts.published_at precision. PostHog snapshot retries must keep progressive backoff so transient outages do not exhaust attempts immediately.
## Enum-backed column defaults use the enum
Columns backed by a PHP enum are plain `string` columns (never `$table->enum()`, which breaks PG/MySQL parity), cast to the enum on the model. Their migration default must reference the enum, not a literal: `->default(Theme::DEFAULT->value)` (give the enum a `DEFAULT` constant, like `App\Enums\User\Locale::DEFAULT`), and the model's `$attributes` uses the same constant. If an enum is later deleted, the migrations that import it must be edited in the same change (as done for ImageStyle in 2026_05_07_231552).
diff --git a/.ai/rules/post-hog.md b/.ai/rules/post-hog.md
index acdaf1df5..a4cd03ceb 100644
--- a/.ai/rules/post-hog.md
+++ b/.ai/rules/post-hog.md
@@ -6,4 +6,4 @@ paths:
# Post Hog
## Serialize account publishing snapshots at the final send
-Publishing activity uses SyncAccountPublishingActivity with per-account debounce and overlap protection. It must re-query the latest confirmed PostPlatform when it executes and send the group update in that same job; routing the snapshot through SendEvent can reorder payloads and regress the account's last publication. The backfill command must reuse this job.
+Publishing activity uses SyncAccountPublishingActivity with per-account debounce and overlap protection. It must re-query the latest confirmed published post (publish_status published) when it executes and send the group update in that same job; routing the snapshot through SendEvent can reorder payloads and regress the account's last publication. The backfill command must reuse this job.
diff --git a/.ai/rules/post.md b/.ai/rules/post.md
index 1dcb773cd..3356a6c48 100644
--- a/.ai/rules/post.md
+++ b/.ai/rules/post.md
@@ -9,16 +9,16 @@ paths:
# Post
## FinalizePostPublication is the only post settler
-handle() takes the Post, not a dummy PostPlatform. Every path that can finish the last enabled target must call it: PublishToSocialPlatform, ReconcileGoogleBusinessPost, RecoverStuckPosts, PublishPost::failed, and AbandonGoogleBusinessReview (disconnect / disable during pending_review). No enabled targets on a draft or scheduled post is a no-op — do not mark the post published. A Publishing post with no enabled targets is abandoned in-flight: mark it Failed so it does not sit non-editable forever. Do not mark the post Published / PartiallyPublished / Failed by hand outside Finalize. The one exception is the one-off release split (posts:split-legacy-active), which derives already-settled statuses quietly, with no events or notifications.
+handle() takes the Post. Every path that can finish the post's publication must call it: PublishToSocialPlatform, ReconcileGoogleBusinessPost, RecoverStuckPosts, PublishPost (also for a post without a channel) and PublishPost::failed. It settles from `posts.publish_status` (published → Published; failed/rejected → Failed); a post without a channel whose publication is not finished is a no-op unless it is Publishing, which becomes Failed (with `posts.errors.choose_channel` when it still has a destination snapshot) so it does not sit non-editable forever. Do not mark the post Published / Failed by hand outside Finalize. Finalize keeps the publish time the publisher wrote (`markAsPublished()` only fills `published_at` when it is empty).
## Finalize is idempotent once the post is settled
-handle() lockForUpdates the post and returns without notifying when status is already Published, PartiallyPublished, or Failed (Status::isSettled()). RecoverStuckPosts and ReconcileGoogleBusinessPost can both finish the last target at the 24h ceiling; the second call must not send a second email. Dispatch SendNotification only after the transaction commits.
+handle() lockForUpdates the post and returns without notifying when status is already Published or Failed (Status::isSettled()). RecoverStuckPosts and ReconcileGoogleBusinessPost can both finish the post at the 24h ceiling; the second call must not send a second email. Dispatch SendNotification only after the transaction commits.
-## Unchecked destination abandons pending_review with target_disabled
-Abandoning a GBP pending_review because the post destination was unchecked uses posts.errors.target_disabled.
+## Google Business reviews end through reconcile or the 24h ceiling
+A GBP pending_review ends when ReconcileGoogleBusinessPost settles it or when RecoverStuckPosts::failExpiredReview rejects it at the 24h ceiling (prunes the JPEG, then Finalize). Disconnecting a channel deletes its posts, so no review is left without an account.
-## One enabled destination per new post
-New draft/scheduled posts are independent per social account: use CreatePosts/CreateChannelPost, with one enabled PostPlatform per Post; do not revive grouped CreatePost/SyncPostPlatforms writes. Editing uses UpdatePost and may change content type within the same account, never the social account. posts:split-legacy-active (release:trypost-2) splits editable and settled multi-target posts, moving each enabled target to its own post with the status of that target; only in-flight aggregates and settled posts with an unfinished target stay grouped. A split original may retain disabled placeholder targets, so count enabled targets when deciding if it is editable.
+## One destination per post, stored on the post
+A post has at most one destination, stored on the post itself (`posts.social_account_id`, `platform`, `content_type`, `meta`, and the publication fields). Create through CreatePosts/CreateChannelPost; never reintroduce a targets table or grouped writes. Editing uses UpdatePost and may change content type within the same account, never the social account. A legacy draft without a channel (platform null) stays editable as a draft (content, media, labels) and is turned into a channel post only through RecoverEmptyDraft; scheduling or publishing it fails with posts.errors.choose_channel.
## No per-destination validation inside a repurpose batch
ProcessRepurposeItem creates all posts of one run in a single all-or-nothing transaction (CreatePosts batch + SyncOwnedMedia). Never add per-destination/per-platform validation inside CreateChannelPost or SyncOwnedMedia: one bad destination would roll back the whole run, and repurpose items are never retried. Destination health stays a publish-time failure (AGENTS.md "Repurpose account health"); platform rules belong in the FormRequests/CreatePosts validation for user-driven flows.
diff --git a/.ai/rules/social.md b/.ai/rules/social.md
index 4c4cdb172..49961f621 100644
--- a/.ai/rules/social.md
+++ b/.ai/rules/social.md
@@ -4,7 +4,6 @@ paths:
- app/Support/Social/GoogleBusinessDerivativeCleaner.php
- app/Actions/Post/DeletePost.php
- app/Actions/Post/UpdatePost.php
- - app/Support/Social/AbandonGoogleBusinessReview.php
- app/Actions/Workspace/PurgeWorkspace.php
- app/Http/Controllers/Auth/SocialController.php
- app/Support/Social/ThreadProgress.php
@@ -17,10 +16,10 @@ paths:
# Social
## GBP JPEG must outlive PROCESSING
-Google fetches Local Post sourceUrl after create while LocalPostState is Processing/Scheduled/Unspecified. Keep the JPEG derivative on disk until reconcile settle() or RecoverStuckPosts fails the target. Deleting in publish() finally races PHOTO_FETCH_FAILED. Live/Rejected on the create response may prune immediately. Path is deterministic: google-business-derivatives/{postPlatformId}.jpg. Wire values live on App\Enums\GoogleBusiness\LocalPostState — never compare raw PROCESSING/SCHEDULED strings. RecoverStuckPosts must prune the JPEG on the 1h Publishing/Pending/Retrying timeout (the worker can die after writing the file and before PendingReview), on a disabled GBP target (reconcile and the 24h ceiling skip `enabled=false`), and again on the 24h review ceiling. UpdatePost mass-updates `enabled=false` (observers never fire): abandon any GBP still in pending_review that is not in the kept set, then prune leftover JPEGs after commit. Disconnect deletes the channel's posts (`DeleteChannelPosts`) before the account delete and prunes their JPEGs, so no pending_review row is left with a null account. DeletePost and PurgeWorkspace must prune every Google Business PostPlatform JPEG before the row disappears — a user delete during pending_review or a workspace wipe would otherwise leak the file, and a DB cascade on post_platforms does not fire Eloquent observers.
+Google fetches Local Post sourceUrl after create while LocalPostState is Processing/Scheduled/Unspecified. Keep the JPEG derivative on disk until reconcile settle() or RecoverStuckPosts fails the post. Deleting in publish() finally races PHOTO_FETCH_FAILED. Live/Rejected on the create response may prune immediately. Path is deterministic: GoogleBusinessDerivativeCleaner::pathFor($post) = google-business-derivatives/{post id}.jpg. Wire values live on App\Enums\GoogleBusiness\LocalPostState — never compare raw PROCESSING/SCHEDULED strings. RecoverStuckPosts must prune the JPEG on the 1h Publishing/Pending/Retrying timeout (the worker can die after writing the file and before PendingReview) and again on the 24h review ceiling. Disconnect deletes the channel's posts (`DeleteChannelPosts`) before the account delete and prunes their JPEGs, so no pending_review row is left with a null account. DeletePost and PurgeWorkspace must prune every Google Business post JPEG before the row disappears — a user delete during pending_review or a workspace wipe would otherwise leak the file, and a DB cascade does not fire Eloquent observers.
## Thread checkpoints resume, never re-post
-Bluesky/Mastodon/X thread segments already live are checkpointed in post_platforms.error_context.thread_progress (ThreadProgress) after each segment, so any retry (incl. posts:retry, which keeps them) resumes from the next segment. A resume keeps the stored root hash: the root is the target's identity even if the same text would now hash differently. Never clear thread_progress on retry and never re-post a checkpointed segment. thread_reply_ids lists the reply ids so ImportExternalPosts skips TryPost's own replies.
+Bluesky/Mastodon/X thread segments already live are checkpointed in posts.error_context.thread_progress (ThreadProgress) after each segment, so any retry (incl. posts:retry, which keeps them) resumes from the next segment. A resume keeps the stored root hash: the root is the post's identity even if the same text would now hash differently. Never clear thread_progress on retry and never re-post a checkpointed segment. thread_reply_ids lists the reply ids so ImportExternalPosts skips TryPost's own replies.
## No server-side aspect ratio crop or meta.aspect_ratio
-Removed October 2026 by user decision: the Facebook and Instagram publishers send every image at its own ratio, and there is no `meta.aspect_ratio` (rule, enum, crop helper and composer radios are gone; migration 2026_10_04_202400 strips stored keys, except on IG feed / FB post targets that will publish without user action (pending/publishing/retrying on a scheduled, pending approval or publishing post), whose crop the one-off `posts:bake-aspect-ratio-crops` release step bakes into the post's media with main's crop math before dropping the key, so pre-2.0 scheduled posts publish the image they were planned with; drafts and failed targets are not baked, keep their original image and lose the key (owner decision 2026-10-06); `MediaOptimizer::cropToAspectRatio` exists only for that step). Instagram feed images outside 3:4–1.91:1 are caught at save by ContentTypeCompatibleWithMedia and fixed per image in the media editor; Facebook feed accepts any ratio. Stories still go through FitsImageToCanvas. Do not reintroduce a ratio selector or a publish-time crop.
+Removed October 2026 by user decision: the Facebook and Instagram publishers send every image at its own ratio, and there is no `meta.aspect_ratio` (rule, enum, crop helper and composer radios are gone; migration 2026_10_04_202400 strips stored keys; the one-off 2.0 release step that baked pending crops into the media was removed after TryPost 2.0). Instagram feed images outside 3:4–1.91:1 are caught at save by ContentTypeCompatibleWithMedia and fixed per image in the media editor; Facebook feed accepts any ratio. Stories still go through FitsImageToCanvas. Do not reintroduce a ratio selector or a publish-time crop.
diff --git a/.ai/rules/support.md b/.ai/rules/support.md
index a26e69c8f..8ef76a514 100644
--- a/.ai/rules/support.md
+++ b/.ai/rules/support.md
@@ -6,4 +6,4 @@ paths:
# Support
## Never use a cross-field Laravel rule (required_unless, required_if, etc.) for a single platform's conditional meta field
-`rules()` is shared by every platform via `platforms.*.meta.*` wildcards. Rules like `required_unless`/`required_if` are Laravel "implicit" rules — they validate even when the field itself is absent from the request. Adding one scoped in spirit to a single platform (e.g. `call_to_action.url` required_unless action_type is NONE/CALL, meant only for Google Business Profile) breaks every OTHER platform's create/update through web, API, and MCP, because their requests never send that field at all and the implicit rule still fires. The correct pattern (already used by Pinterest's `board_id`, Discord's `channel_id`): keep the field's `rules()` entry unconditional (`sometimes|nullable|...`), and enforce "required" semantics only in `requiredMetaViolation()`'s `match(true)` block, which is evaluated per the resolved `Platform` of the row being checked. This bug shipped once (fixed in commit 8887b3f3) and broke Pinterest/Discord/TikTok post updates — the task reviewer that approved the original `rules()` entry didn't catch it because it only reviewed Google Business's own test coverage, not sibling platforms'.
+`rules($prefix)` is shared by every platform (`meta.*` on a single post, `destinations.*.meta.*` in batches). Rules like `required_unless`/`required_if` are Laravel "implicit" rules — they validate even when the field itself is absent from the request. Adding one scoped in spirit to a single platform (e.g. `call_to_action.url` required_unless action_type is NONE/CALL, meant only for Google Business Profile) breaks every OTHER platform's create/update through web, API, and MCP, because their requests never send that field at all and the implicit rule still fires. The correct pattern (already used by Pinterest's `board_id`, Discord's `channel_id`): keep the field's `rules()` entry unconditional (`sometimes|nullable|...`), and enforce "required" semantics only in `requiredMetaViolation()`'s `match(true)` block, which is evaluated per the resolved `Platform` of the row being checked. This bug shipped once (fixed in commit 8887b3f3) and broke Pinterest/Discord/TikTok post updates — the task reviewer that approved the original `rules()` entry didn't catch it because it only reviewed Google Business's own test coverage, not sibling platforms'.
diff --git a/AGENTS.md b/AGENTS.md
index c66a636f6..5a31bde8a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -436,13 +436,10 @@ approval, failed, sent and imported) goes with its media. There is no orphaned
history — a post without a channel is a history that no longer exists.
- Quiet: no `post.deleted` webhook or notification per post.
-- A legacy post with another live, enabled target keeps that target and loses
- only this one (a Publishing post is re-settled through `FinalizePostPublication`).
+- It deletes by `posts.social_account_id`, without a workspace clause: a post
+ has one channel, so every post pointing at the account goes.
- Analytics publications stay and are only unlinked, never dismissed, so
reconnecting the same identity re-imports its recent posts once.
-- Orphans left by disconnects before this rule are removed once by the release
- script (`release:trypost-2`, step `posts:purge-orphaned`, in
- `app/Console/Commands/Scripts/`), through the same action.
## Member permissions and post approvals
@@ -488,11 +485,16 @@ publishes directly.
- A repurpose item created by a member who requires approval stays marked
Published while its posts are pending approval: the item records the hand-off to
the queue, not the network publication.
-- Approval emails go through `SendNotification` with `Type::Collaboration`.
- Requests email every approver except the requester, list only the posts still
- pending when the job runs and are dropped when none is; decisions are grouped
- per `post_group_id`, approver and requester by `NotifyApprovalDecision`
- (cache + one unique delayed job).
+- Approval emails go through `SendNotification` with `Type::Collaboration`, one
+ email per post (owner decision October 2026): posts created together for
+ several channels are separate posts, each approved on its own, so they are
+ never grouped by `post_group_id`. A request emails every approver except the
+ requester and is dropped when its post is no longer pending; a decision
+ (`NotifyApprovalDecision`) emails the requester once it commits and is dropped
+ when its post was deleted.
+- The published and failed emails (`FinalizePostPublication::notify()`) go to
+ every member of the workspace (owner decision October 2026; the owner is always
+ a member); each user's notification preferences still decide whether it is sent.
## UI locale (`users.locale`)
@@ -747,13 +749,52 @@ TryPost runs on **both PostgreSQL and MySQL**. Cloud runs PostgreSQL; a self-hos
- **JSON object key order is not preserved.** MySQL reorders object keys on storage (by length, then lexicographically); PostgreSQL keeps insertion order. Assert JSON read back from the database with `toEqual` (recursive, order-independent), never `toBe`/`assertSame`. Array *element* order is preserved on both.
- **`$table->timestamp()` tops out at 2038-01-19.** PostgreSQL has no such limit, so 2038-01-19 is the app's ceiling: nothing written to a `timestamp()` column may go past it — scheduled posts, expiry sentinels and test fixtures alike. `2037-12-31` reads as "far future" and works on both. Do not widen a column to escape the limit without a deliberate decision; it changes what self-hosted MySQL installs can store.
- **Raw query-builder reads carry no Eloquent cast**, so the driver's native shape leaks through: `DB::table(...)->value('some_bool')` is `true` on PostgreSQL and `1` on MySQL. Read through the model, or use `assertDatabaseHas`.
- - **Identifier quoting differs** — PostgreSQL emits `"post_platforms"`, MySQL emits backticks. Never match logged SQL (`DB::listen`) against a quoted identifier.
+ - **Identifier quoting differs** — PostgreSQL emits `"posts"`, MySQL emits backticks. Never match logged SQL (`DB::listen`) against a quoted identifier.
- **MySQL refuses to drop the only index backing a foreign key** (SQLSTATE `1553`). A migration `down()` that drops a unique whose leftmost prefix is an FK column must create a standalone index for that column first.
- **DDL implicitly commits**, which defeats `RefreshDatabase`'s rollback: schema changes made inside a test leak into the tests that follow. Keep them idempotent.
-## Per-Platform Post Meta (`PostPlatform.meta`)
-
-- All `platforms.*.meta` validation (the parent array rule AND every per-platform sub-key: TikTok `privacy_level`/flags, Pinterest `board_id`, Discord `channel_id`/`mentions`/`embeds`, etc.) lives in ONE place: `App\Support\PostPlatformMetaRules`.
+## One destination per post (`posts` carries its channel)
+
+A post has at most one channel, stored on the post row: `social_account_id`,
+`platform`, `content_type`, `meta`, the channel snapshot (`platform_name`,
+`platform_username`, `platform_avatar`) and the publication result
+(`publish_status`, `platform_post_id`, `platform_url`, `error_message`,
+`error_context`, `thread_reply_ids`, `submitted_at`, `last_reconciled_at`,
+`retry_at`, `connection_warning_sent_at`). The `post_platforms` table was merged
+into `posts` in October 2026 (owner decision); never reintroduce a targets table,
+an `enabled` flag or posts with several channels. Posts created together share
+`post_group_id`.
+
+- **Two status columns.** `posts.status` is the lifecycle (draft,
+ pending_approval, scheduled, publishing, published, failed). `posts.publish_status`
+ (`App\Enums\Post\PublishStatus`) is the network side (pending, publishing,
+ retrying, pending_review, published, failed, rejected). Publishers write
+ `publish_status` through the `markPublication*()` methods; only
+ `FinalizePostPublication` moves `status` to published or failed. There is no
+ `partially_published`.
+- **Publication writes have their own clock.** `writePublication()` (and every
+ `markPublication*()`) sets `publication_updated_at` and leaves `updated_at`
+ alone, so a publish attempt never moves the post's `updated_at` (API, webhooks)
+ and a label or note never moves the publication clock. System writes by query
+ (`toBase()->update`) follow the same rule.
+- **A draft may have no channel.** Drafts written before every post had one
+ channel keep `platform` null. They stay editable as drafts and become channel
+ posts only through `RecoverEmptyDraft`; scheduling or publishing one fails with
+ `posts.errors.choose_channel`. `platform`/`content_type` therefore stay nullable.
+ `Post::hasDestination()` (platform set) and `Post::hasChannel()` (account still
+ there) are the two checks.
+- **Not fillable on purpose:** `social_account_id`, `platform_post_id`,
+ `publish_status`, `scheduled_before_media_checks` — set them
+ with `forceCreate`/`forceFill` in `CreateChannelPost`, `ImportExternalPosts` and
+ the publication methods only.
+- **Public contract:** API, MCP and webhook payloads carry the channel fields at
+ the top of the post (no `platforms[]`); inputs take top-level
+ `social_account_id` / `content_type` / `meta`, and validation errors are keyed
+ `meta.*` (`destinations.{i}.meta.*` in batches).
+
+## Per-Platform Post Meta (`posts.meta`)
+
+- All `meta` validation (the parent array rule AND every per-platform sub-key: TikTok `privacy_level`/flags, Pinterest `board_id`, Discord `channel_id`/`mentions`/`embeds`, etc.) lives in ONE place: `App\Support\PostPlatformMetaRules`.
- Every post create/update entry point — web (`App\Http\Requests\App\Post\UpdatePostRequest`), public API (`App\Http\Requests\Api\Post\{Store,Update}PostRequest`), and MCP (`App\Mcp\Tools\Post\{Create,Update}PostTool`) — spreads `...PostPlatformMetaRules::rules()`. NEVER add a per-platform meta rule inline to a single request/tool.
- Why: `FormRequest::validated()` (and MCP `$request->validate()`) STRIPS any key without a rule. A meta field defined in only one entry point is silently dropped everywhere else — which is exactly how Discord/Pinterest/TikTok meta was lost via API/MCP before this was centralized.
- Required-on-publish (meta a platform needs to publish, e.g. Discord `channel_id`) also lives there: `addRequiredOnPublishErrors()` for request-driven flows (web/API update `withValidator`), `assertStoredPostPublishable()` for flows that publish stored state without resubmitting platforms (MCP `PublishPostTool`). Add new required-meta rules to `requiredMetaViolation()`, not inline.
@@ -776,8 +817,8 @@ TryPost runs on **both PostgreSQL and MySQL**. Cloud runs PostgreSQL; a self-hos
- Reply media rows are owned by the post like its root media. `SyncOwnedMedia` resolves them from the post's stored `meta.thread_replies` and writes the canonical items back, so write the target's meta **before** calling it; it never deletes a row a reply still uses. Every segment's checkpoint hash covers its media ids.
- In the composer each post of a thread shows its own media under it; the toolbar, paste, drop and the toolbar upload button add to the active post.
- X replies go through `POST /2/tweets` with `reply.in_reply_to_tweet_id` on the previous segment. X restricts API replies (February 2026) to posts whose author summoned the replier; replying to your own post is the case that stays allowed. Each reply is billed as a post.
-- Each segment already live is checkpointed in `post_platforms.error_context.thread_progress` (`App\Support\Social\ThreadProgress`), so a retry resumes instead of re-posting, and a resume keeps the root hash. `posts:retry` keeps the live segments.
-- `post_platforms.thread_reply_ids` lists the reply ids so `ImportExternalPosts` does not import TryPost's own replies as new posts.
+- Each segment already live is checkpointed in `posts.error_context.thread_progress` (`App\Support\Social\ThreadProgress`), so a retry resumes instead of re-posting, and a resume keeps the root hash. `posts:retry` keeps the live segments.
+- `posts.thread_reply_ids` lists the reply ids so `ImportExternalPosts` does not import TryPost's own replies as new posts.
## Composer steps
@@ -787,7 +828,7 @@ TryPost runs on **both PostgreSQL and MySQL**. Cloud runs PostgreSQL; a self-hos
## Link preview card
-- `meta.link_preview === false` (the × on the card) is honoured only by the Facebook post, Bluesky and LinkedIn publishers, through `PostPlatform::attachesLinkPreview()`. Threads has no ×: its API always cards the first link.
+- `meta.link_preview === false` (the × on the card) is honoured only by the Facebook post, Bluesky and LinkedIn publishers, through `Post::attachesLinkPreview()`. Threads has no ×: its API always cards the first link.
- "Replace link preview with media" goes through `LinkPreviewMediaController`.
## YouTube
@@ -835,7 +876,7 @@ Browser tests live in `tests/Browser` and run on `pestphp/pest-plugin-browser` d
## Eloquent Models & Morph Map
-- EVERY Eloquent model in `app/Models` MUST be registered in `Relation::enforceMorphMap([...])` inside `AppServiceProvider::configureMorphMap()`, keyed by a camelCase alias (e.g. `'postPlatform' => PostPlatform::class`).
+- EVERY Eloquent model in `app/Models` MUST be registered in `Relation::enforceMorphMap([...])` inside `AppServiceProvider::configureMorphMap()`, keyed by a camelCase alias (e.g. `'postTemplate' => PostTemplate::class`).
- When you add a new model, add it to the morph map in the same change. `tests/Unit/MorphMapTest.php` fails if any model is missing.
- The alias is persisted in polymorphic columns, so never rename or remove an existing alias for a model that has stored rows.
diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md
index ecb0eeab5..4acdc438f 100644
--- a/DEPLOYMENT.md
+++ b/DEPLOYMENT.md
@@ -1,416 +1,161 @@
-# Deploy da branch `codex/independent-social-posts`
+# Deploy da fusão de `post_platforms` em `posts`
-Roteiro completo para levar esta branch à produção, na ordem. Todos os comandos, variáveis e
-agendamentos citados aqui já existem na branch, incluindo os da importação de
-posts externos.
+Roteiro para levar à produção a branch `feat/merge-post-platforms-into-posts`, na
+ordem. Cada post passa a guardar o seu canal e o resultado da publicação na
+própria linha de `posts`; a tabela `post_platforms` é copiada para `posts` e
+removida no mesmo deploy.
-O roteiro detalhado da mídia (snapshot do bucket, Google Drive/Photos, Canva, HEIC,
-testes manuais e comunicação) continua em
-[`MEDIA_INTEGRATIONS_SETUP.md` §7](MEDIA_INTEGRATIONS_SETUP.md). Este arquivo
-referencia aquela seção em vez de repeti-la.
+> **Pré-requisito antes do merge:** marcar a `main` atual (o 2.0 que está no ar,
+> `311767990`) com a tag e a release `v2.0.0`. Hoje a última tag é a `v1.1.0`, e
+> sem a `v2.0.0` o self-hosted não tem por onde passar pelo `release:trypost-2`,
+> que esta versão remove.
+
+O roteiro do deploy do TryPost 2.0 (`release:trypost-2`) fica na tag `v2.0.0`.
+Self-hosted que ainda está na `v1.1.0` **precisa passar por ela antes**: atualizar
+para a `v2.0.0`, rodar `php artisan release:trypost-2 --force` e só então
+atualizar para esta versão. A primeira migration desta versão para com uma
+mensagem explicando isso se encontrar posts com mais de um canal.
+
+Por ser um deploy só (sem releases intermediários), ele roda **em manutenção, com
+todas as filas drenadas**: nenhum job antigo, guardando o id de um destino que
+deixa de existir, pode sobrar para o código novo.
---
## 1. Antes do deploy
-> **Snapshot do storage (R2/S3) ou versionamento ligado, sem exceção.** A adoção da
-> biblioteca de mídia (passo 3 do `release:trypost-2`) apaga arquivos que só
-> existiam na biblioteca, e não há ferramenta de export. Detalhes em
-> `MEDIA_INTEGRATIONS_SETUP.md` §7.1.
-
-1. **Snapshot do storage** (acima).
-2. **Ensaio numa cópia da produção**, depois do `migrate`:
+1. **Ensaio numa cópia da produção** (banco e bucket próprios), com o código desta
+ branch:
```bash
- php artisan release:trypost-2 --dry-run # mostra o que cada passo faria, não muda nada
+ php artisan migrate --force
```
- > **Nunca rode `--force` numa cópia do banco que aponta para o bucket de
- > produção.** A adoção apaga da cópia as linhas da biblioteca e, em seguida, os
- > arquivos que nenhuma linha *da cópia* usa; os posts de produção ainda usam esses
- > arquivos, e a perda só volta pelo snapshot. Para ensaiar de verdade (saída e
- > tempo), a cópia precisa de **um bucket só dela** (uma cópia do bucket, ou
- > `FILESYSTEM_DISK=local` sem credenciais de produção). Sem isso, só `--dry-run`.
+ As quatro migrations novas (`2026_10_08_2229*`) devem terminar sem erro. A
+ primeira resolve sozinha os restos do split do 2.0: apaga os destinos cuja
+ conta sumiu e os destinos desligados que nunca publicaram, transforma em rascunho os posts não
+ publicados sem canal, apaga (com a mídia, sem webhook) os posts publicados sem
+ canal (os arquivos só depois do commit, e só os que nenhuma outra mídia usa)
+ e resolve os `partially_published` com um destino só. Ela **para**, sem
+ mudar nada, se ainda houver: post com mais de um destino, destino desligado
+ que publicou, `partially_published` com destino em andamento ou destino de
+ outro workspace. A mensagem diz quantos de cada.
+
+ Na cópia de produção de 09/10/2026 eram 45.520 destinos desligados, 478
+ rascunhos e 1 post com falha sem canal, e 139 posts publicados sem canal (em 4
+ workspaces, com 236 arquivos), apagados por decisão do Paulo.
+ Anote quanto tempo o `migrate` levou (estimativa: 20–60 s para ~20 mil posts).
+2. **Se o ensaio parou na primeira migration**, resolva os casos na produção antes
+ do deploy (com o código que está no ar):
```bash
- php artisan release:trypost-2 --force # só numa cópia com bucket próprio
+ php artisan posts:split-legacy-active
+ php artisan posts:audit-legacy --strict
```
- O dry-run mostra quantos posts o passo 1 apagaria e quantos destinos tiraria, a
- tabela do `posts:audit-legacy` (o passo 2 divide os "Editable with multiple
- enabled targets"), o plano da adoção por workspace e a auditoria atual da mídia.
-3. **Imagem Docker** reconstruída com o `docker/Dockerfile` novo (Imagick + HEIC).
-4. **Variáveis de ambiente** (seção 2).
-5. **Conte os posts agendados do Instagram com mais de 5 hashtags** (só leitura):
+ O que sobrar (alvo preso em revisão, mídia que não copiou) é tratado à mão.
+3. **Script de deploy do Forge**: adicione, depois de `$RESTART_QUEUES()`,
```bash
- php artisan tinker --execute '$tag = "/(?:^|[^\p{L}\p{N}_\/])#(?=[\p{N}_]*\p{L})[\p{L}\p{N}_]+/u"; echo App\Models\PostPlatform::query()->where("enabled", true)->whereIn("platform", ["instagram", "instagram-facebook"])->where("content_type", "!=", "instagram_story")->whereHas("post", fn ($post) => $post->where("status", "scheduled"))->with("post:id,content")->get()->filter(fn ($target) => preg_match_all($tag, html_entity_decode(preg_replace("/<[^>]+>/", " ", (string) $target->post->content))) > 5)->count(), PHP_EOL;'
+ $FORGE_PHP artisan horizon:terminate
```
- Troque `->count()` por `->pluck("post_id")` para ver quais são. O comando roda
- com o código que está em produção hoje (não depende de nada desta branch). O
- limite de 5 hashtags vale só ao salvar (web, API e MCP): esses posts continuam
- publicando, mas quem abrir um deles para editar precisa tirar as hashtags a
- mais antes de salvar. Stories não contam (a legenda não é enviada).
-
-## 2. Variáveis de ambiente
-
-As da mídia estão em `MEDIA_INTEGRATIONS_SETUP.md` §7.2. Novas desta branch:
-
-| Variável | Padrão | Para quê | Status |
-| --- | --- | --- | --- |
-| `TELEGRAM_DEEP_LINK` | `https://t.me` | Link do bot no fluxo de conectar o Telegram | já existe |
-| `EXTERNAL_POSTS_IMPORT_LIMIT` | `50` | Quantos posts mais recentes por canal viram post em Enviados (`0` desliga a importação) | já existe |
-| `X_EXTERNAL_POSTS_IMPORT_DAYS` | `30` | No X, importar só os posts dos últimos N dias | já existe |
-| `EXTERNAL_POSTS_MATCH_WINDOW_MINUTES` | `120` | Minutos em torno de um post da rede em que um post do TryPost com o mesmo texto é tratado como esse post, em vez de importar uma cópia | nova |
-| `EXTERNAL_POSTS_MEDIA_REQUESTS_PER_MINUTE` | `30` | Chamadas por minuto, por rede, para buscar as mídias dos posts importados (orçamento separado do Analytics) | nova |
-| `PUBLICATION_DISCOVERY_INTERVAL_HOURS` | `3` | Intervalo da busca de posts novos nas redes | já existe |
-| `HORIZON_MEDIA_ADOPTION_PROCESSES` | `20` | Processos do supervisor `media-adoption` em produção, que adota a biblioteca em paralelo no passo 3 do release | nova |
-| `X_PUBLICATION_DISCOVERY_INTERVAL_HOURS` | `24` | Mesmo intervalo, só para o X (cobra por leitura) | já existe |
-| `PUBLICATION_DISCOVERY_OVERLAP_HOURS` | `6` | Margem para trás em cada busca, para não perder post atrasado | já existe |
-
-Depois de mudar variáveis:
-
-```bash
-php artisan config:clear
-```
+ Sem ele o Horizon reinicia os workers dentro da pasta do release antigo e
+ continua rodando o código antigo. Vale para todo deploy, não só este. Confira
+ também que `storage/` é compartilhado entre releases (`readlink releases/*/storage`).
+4. **Comunicação**: avise os usuários do horário da janela (alguns minutos sem
+ publicar; os posts agendados para a janela saem logo depois). Avise quem usa a
+ API, o MCP ou webhooks sobre a mudança de contrato (seção 4) **antes** do deploy.
-## 3. Deploy, na ordem
+## 2. Deploy, na ordem
-1. **Deploy** normal. Ele roda `php artisan migrate --force` (esquema: grupos de
- posts, recorrência, origem do post, aprovações etc.).
- - `widen_content_on_posts_table`: `posts.content` vira `mediumText` para caber o post longo do X (25000 caracteres).
- - **PostgreSQL** (Cloud): não reescreve a tabela; é instantâneo.
- - **MySQL** (self-hosted): `TEXT` → `MEDIUMTEXT` **recria a tabela `posts`**
- (`ALGORITHM=COPY`), e as escritas em `posts` ficam bloqueadas enquanto ela é
- copiada. Numa tabela grande, planeje uma janela de manutenção e meça antes o
- tempo numa cópia do banco com o mesmo tamanho.
- - O `down()` falha (ou corta o texto) depois que algum post passar de 64 KB.
- - `add_thread_reply_ids_to_post_platforms_table`: coluna JSON nula
- (`thread_reply_ids`), instantânea nos dois bancos.
-2. **Passos únicos do release**, num comando só:
+1. No release atual:
```bash
- php artisan release:trypost-2 --force
+ php artisan down --retry=60 --secret=
+ ```
+ Web, Horizon e scheduler param (nenhum agendamento usa `evenInMaintenanceMode`).
+2. **Drene todas as filas** com o código antigo (o `--force` processa mesmo em
+ manutenção):
+ ```bash
+ php artisan queue:work redis --queue=social-linkedin,social-linkedin-page,social-x,social-tiktok,social-youtube,social-facebook,social-instagram,social-instagram-facebook,social-threads,social-pinterest,social-bluesky,social-mastodon,social-telegram,social-discord,social-google_business,default,posthog,broadcasts,analytics,webhooks,media-imports,media-adoption,rss-feeds --force --stop-when-empty
+ ```
+ Confira a lista de filas em `config/horizon.php`. Espere também os jobs com
+ atraso das filas `social-*`, `default`, `broadcasts`, `posthog` e `webhooks`
+ (até 10 minutos, os retries de rede indisponível) e rode o comando de novo até
+ o Redis não ter nada em `queues::delayed` nem em
+ `queues:*:reserved`. Os atrasados da fila `analytics` (métricas de stories, até
+ 24 h) só carregam ids de publicação e podem ficar.
+ Os jobs que já falharam (`failed_jobs`) com o código antigo não podem ser
+ reexecutados depois do deploy (a classe `PostPlatform` deixa de existir):
+ reexecute-os antes ou descarte-os.
+ Confirme que não sobrou comando agendado rodando:
+ `ps -ef | grep "[a]rtisan" | grep -v horizon`.
+ E que nenhum post ficou publicando sem job (deve dar 0; o que sobrar o
+ `RecoverStuckPosts` falharia depois de 1 h). As consultas abaixo são de
+ PostgreSQL (Cloud); no MySQL troque `now() - interval '1 hour'` por
+ `now() - interval 1 hour`:
+ ```sql
+ select count(*) from post_platforms
+ where status in ('publishing', 'retrying') and retry_at is null
+ and updated_at > now() - interval '1 hour';
+ ```
+ E que nenhum post do Google Business está em revisão (deve dar 0; o JPEG de um
+ post em revisão tem o id do destino antigo no nome e ficaria órfão no bucket):
+ ```sql
+ select count(*) from post_platforms
+ where platform = 'google_business' and status = 'pending_review';
+ ```
+3. Deploy pelo Forge, com o build **antes** do `migrate`, para um build quebrado
+ falhar antes de qualquer escrita:
+ `composer install` → `artisan optimize` → `npm ci && npm run build` →
+ `artisan migrate --force` → `$ACTIVATE_RELEASE()` → `$RESTART_QUEUES()` →
+ `artisan horizon:terminate` → `artisan reverb:restart`.
+4. Teste rápido pelo link secreto, ainda em manutenção: abrir a página de publicar,
+ abrir um post enviado e um agendado, salvar um rascunho.
+5. ```bash
+ php artisan up
```
-3. **Acompanhe o Horizon** até as filas `analytics` e `media-imports` drenarem
- (backfill do Analytics, importação dos posts externos e das mídias deles).
-4. **Checagens pós-deploy** (seção 6).
-
-O `release:trypost-2` para com erro, sem fazer nada, se houver migration pendente.
-Roda os passos nesta ordem, imprime a saída de cada um e termina com uma tabela de
-resumo:
-
-| # | Passo | Comando reaproveitado |
-| --- | --- | --- |
-| 1 | Apaga os posts que ficaram sem canal por desconexões antigas (agora desconectar apaga os posts do canal); num post com outro canal vivo, só tira o destino morto | `posts:purge-orphaned` |
-| 2 | Divide posts antigos com várias redes em um post por rede, ligados pelo mesmo `post_group_id`: rascunhos e agendados, e também os já terminados (publicado, parcialmente publicado, falhou). Cada post terminado fica com o status do próprio destino (publicado → `published`, falhou/rejeitado → `failed`); `partially_published` deixa de existir | `posts:split-legacy-active` |
-| 3 | Biblioteca de mídia antiga → mídia por post e por ideia: um `AdoptWorkspaceLibraryJob` por workspace na fila `media-adoption` (em paralelo no Horizon), e o comando **espera** até terminar | `media:adopt-library` |
-| 4 | Backfill do Analytics + importação dos posts externos (assíncrono, seção 5) | `analytics:backfill-existing` |
-| 5 | Busca imediata dos stories do Instagram (assíncrono) | `analytics:dispatch-publication-discovery --platform=instagram --platform=instagram-facebook` |
-| 6 | Auditoria da mídia (só leitura), comparada com a de antes do release | `AuditMedia` |
-
-- **A ordem importa.** Purgar antes de dividir: um post com um destino num canal
- apagado e outro num canal vivo perde só o destino morto e mantém o id (dividido
- antes, a metade morta podia levar o id original junto). Purgar antes de adotar
- evita copiar mídia de posts que vão ser apagados. A divisão dá a cada post novo
- as próprias mídias (arquivo e linha), antes ou depois da adoção, então a adoção
- no boot do Docker não atrapalha.
-- **Como a divisão trata o histórico.** O destino (`post_platforms`) é **movido**
- para o post novo, nunca copiado: link do post na rede, erro, `published_at`,
- métricas e o vínculo do Analytics (`analytics_publications.post_platform_id`)
- continuam no mesmo registro. Cada post novo ganha o mesmo texto, autor, origem,
- datas, etiquetas, notas e as próprias mídias. Destinos desligados não viram post
- e ficam no post original, como histórico de um destino que nunca rodou. Posts em
- `publishing` e posts terminados com algum destino ainda em andamento (ex.: Google
- Business em revisão) ficam como estão; o comando diz quantos. Um post
- `partially_published` que ficou com um só destino (o outro era de um canal
- apagado e saiu no passo 1) passa a ter o status desse destino. Nada disso dispara
- e-mail, webhook ou evento.
-- **Rodar de novo é seguro:** cada passo pula o que já foi feito. Se um passo
- falhar, o comando segue para os outros, mostra o resumo e sai com código
- diferente de 0; corrija e rode de novo.
-- **O backfill (passo 4) é despachado uma vez só.** Os jobs dele podem ficar horas
- esperando por causa do `--backfill-delay`; despachar de novo antes deles rodarem
- leria cada conta duas vezes (o X cobra por leitura). O comando guarda no cache
- quando despachou (na tabela `cache` do banco, não no Redis: sobrevive a um flush
- e viaja junto com um snapshot do banco) e, nas próximas rodadas, pula o passo
- ("Skipped: already dispatched at …"). Para despachar de novo de propósito:
- `--rerun-backfill`.
-- **Adoção (passo 3).** O comando enfileira um job por workspace com biblioteca na
- fila `media-adoption` e fica esperando, com uma linha de progresso a cada 15s
- (`N/M workspace(s) done, X library row(s) left, Y stopped`). Só segue para os
- passos 4–6 quando todos terminam ou quando passa o `--adoption-timeout` (padrão
- `21600`, 6h). Dentro de um workspace a cópia é sequencial (um job por workspace:
- a biblioteca só é apagada depois de todas as cópias, e dividir um workspace em
- vários jobs quebraria essa garantia). Um workspace com post em `Publishing` é
- tentado de novo pelo próprio job (a cada 5 min, por até 6h). Ao fim da espera:
- - ainda rodando: fica para o job, que continua sozinho; o resumo diz quantos;
- - parado com biblioteca sobrando (o job desistiu, ou os arquivos não foram
- achados no disco): o passo falha, o resumo lista os workspaces e o comando sai
- com código diferente de 0; veja o log, corrija e rode
- `php artisan media:adopt-library --force`.
-
- Rodar o release de novo com jobs ainda na fila não duplica nada (job único por
- workspace) e volta a esperar.
-- **Tempo da adoção.** Cada cópia no R2 levou ~1,9s na cópia de produção
- (sequencial). Tempo total ≈ cópias × 1,9s ÷ processos do supervisor
- `media-adoption`, limitado pelo maior workspace (um workspace roda num processo
- só). Exemplo: ~14,6 mil cópias com 20 processos ≈ 14.600 × 1,9 ÷ 20 ≈ 23 min.
- No release, deixe `HORIZON_MEDIA_ADOPTION_PROCESSES` em 20 (padrão em produção;
- dá para subir para 30–40 se o servidor aguentar) e reinicie o Horizon
- (`php artisan horizon:terminate`) **antes** de rodar o comando, para o supervisor
- novo existir.
-- **Opções repassadas ao backfill:** `--backfill-chunk=50 --backfill-delay=300`
- (contas por lote e segundos entre lotes; padrão `100` e `0`) e
- `--include-unsubscribed`.
-- **Como ler a auditoria (passo 6).** Antes do passo 1 o comando tira uma foto
- rápida (só banco e a listagem do bucket, sem conferir arquivo por arquivo, então
- não atrasa os passos de dados). No fim, depois dos passos de dados, roda a
- auditoria completa, que confere o arquivo de cada mídia no bucket: conte com
- **uns 10 a 30 minutos** em produção (um pedido ao R2 por mídia; o resumo mostra o
- tempo). Ela mostra por checagem quantos achados são **esperados** e quantos são
- **inesperados**. Esperados:
- - os que já estavam na foto de antes (sujeira antiga, não do release);
- - itens de post que apontam para uma linha da biblioteca que ficou (usada por
- post de outro workspace, ou de um workspace que ficou para a fila);
- - itens que apontam para uma mídia que não existe mais, **só** se ela já era da
- biblioteca ou já estava sumida antes do release;
- - arquivos sem linha que tinham linha antes do release (exclusão na fila do
- `DeleteMediaFiles`), e mídias de antes do release sem arquivo (a foto não
- confere arquivos).
-
- Qualquer outra coisa, como uma mídia ainda usada por um post que o release
- apagou, é **inesperada**: aparece uma por linha (`unexpected json_drift: … media_id=…`)
- e faz o comando sair com código diferente de 0.
-
-Migrations que entram nesta branch (entre outras):
-
-- `add_post_group_id_to_posts_table`: posts criados juntos compartilham um grupo.
-- `add_recurrence_to_posts_table` e `add_recurrence_origin_at_to_posts_table`: posts recorrentes.
-- `add_origin_to_posts_table`, `change_platform_url_to_text_on_post_platforms_table` e `add_post_dismissed_at_to_analytics_publications_table`: origem do post (`trypost` | `network`), permalinks longos e posts importados apagados pelo usuário (não voltam).
-- `add_permissions_to_user_workspace_and_invites_tables` → `drop_role_from_user_workspace_and_invites_tables`:
- os papéis Admin / Member / Viewer viram duas flags por membro e por convite
- (`is_admin`, `requires_approval`). Admin → admin; Member → publica direto;
- Viewer → precisa de aprovação; o dono da conta vira admin. Rodam juntas, nessa
- ordem; a segunda apaga a coluna `role`.
-- `add_approval_columns_to_posts_table`: `approval_requested_at`, `approved_by`,
- `approved_at`, `approval_queue_position` e o novo status `pending_approval`.
-- `add_approval_requested_by_to_posts_table`: `approval_requested_by`, quem pediu
- a aprovação (pode não ser o autor: um membro que edita um post já aprovado de
- outra pessoa). Vira `null` se o usuário for apagado; aí vale o autor do post.
-- `add_collaboration_to_notification_preferences_table`: preferência
- "Colaboração" (ligada por padrão).
-- `flatten_replies_and_drop_reactions_from_post_notes_table`: notas ficam
- simples (sem respostas nem reações). Respostas viram notas normais do mesmo
- post, na ordem em que foram criadas; as colunas `parent_id` e `reactions` são
- apagadas (reações somem).
-- `fold_legacy_workspace_media_into_library`: as imagens de IA antigas (coleção
- `ai-generated`, algumas gravadas com `App\Models\Workspace` em vez do alias
- `workspace`) entram na biblioteca, para a adoção copiá-las para os posts que as
- usam e apagar as que nenhum post usa. Linhas `assets` gravadas com o nome da
- classe também passam a ser adotadas. Linhas de outras coleções (ex.: `logo`) e de
- workspaces que não existem mais ficam como estão.
-- `make_time_format_required_on_users_table`: `users.time_format` passa a ser
- obrigatório (12h ou 24h; acaba o "seguir o idioma"). Quem estava sem formato
- recebe o que já via: `12h` em inglês, `24h` nos outros idiomas. Ninguém percebe
- mudança.
-
-Sem migração de dados para a fila: posts já na fila mantêm os horários que têm
-(a fila não é mais compactada nos primeiros horários livres), então nada muda
-para quem já tem posts enfileirados.
-
-## 4. Filas e scheduler
-
-Os workers/Horizon precisam consumir todas estas filas (`config/horizon.php`):
-
-| Fila | Para quê |
-| --- | --- |
-| `default`, `posthog`, `broadcasts` | App em geral |
-| `webhooks` | Webhooks de saída |
-| `analytics` | Analytics e, com a importação, a criação dos posts externos |
-| `media-imports` | Google Drive, Photos, Canva e, com a importação, as mídias dos posts externos |
-| `media-adoption` | Adoção da biblioteca de mídia (só neste release). Supervisor próprio no Horizon; em produção até `HORIZON_MEDIA_ADOPTION_PROCESSES` processos (padrão 20) |
-| `rss-feeds` | Feeds RSS |
-
-Com `queue:work` em vez de Horizon:
-
-```bash
-php artisan queue:work --queue=default,posthog,broadcasts,webhooks,analytics,media-imports,media-adoption,rss-feeds
-```
-
-Agendamentos (o scheduler já cuida, só confirme que ele está rodando):
-
-| Quando (UTC) | O quê | Status |
-| --- | --- | --- |
-| a cada 3h (X: a cada 24h, às 00:00) | Busca posts novos nas redes: importa para Enviados e Analytics | já existe |
-| 02:00 | Snapshot de seguidores | já existe |
-| 03:00 | Atualização das métricas dos últimos 30 dias (X: 20) | já existe |
-| 23:30 / 00:30 | Fechamento do snapshot do dia / recuperação do dia anterior | já existe |
-| de hora em hora | `media:prune-uploads` | já existe |
-| diário | `posts:prune-history` | já existe |
-| a cada 5 min | `rss-feeds:poll`, `repurposes:poll` | já existe |
-
-## 5. Backfill do Analytics + importação dos posts externos
-
-É o passo 4 do `release:trypost-2`, depois da divisão dos posts e da adoção da biblioteca. A ordem importa: o
-backfill primeiro liga os posts que o TryPost já publicou às publicações do
-Analytics e só então importa os posts feitos direto nas redes. Ao contrário, um
-post do TryPost viraria um "importado" duplicado.
-
-O comando despacha tudo e termina, não fica parado esperando. O que acontece por
-conta:
-
-1. O Analytics busca o histórico de **365 dias** (no X, no máximo 3.200 posts).
-2. Os **50 posts mais recentes** de cada canal (no X, os dos **últimos 30 dias**)
- viram post em Enviados e no calendário, com origem `network`.
-3. As mídias completas desses posts (carrossel, vídeo, capa) são baixadas para o
- bucket na fila `media-imports`.
-4. As métricas são buscadas pelo Analytics.
-5. No Instagram, os **stories** entram também, mas só os que estão no ar (últimas
- 24 horas): a API não devolve stories mais antigos. Eles têm um limite próprio
- de 50, separado de feed e reels. Para contas que já tinham feito o backfill, o
- passo 5 do release traz os que estão no ar na hora; depois disso a busca de
- posts novos (a cada 3h) cuida deles.
-
-Rodar de novo é seguro: nenhum post é criado em dobro, e mídias já baixadas não são
-baixadas outra vez.
-
-O resumo do backfill tem uma linha por contador:
-
-- `accounts_dispatched`: contas que tiveram o backfill despachado.
-- `publications_to_link`: publicações do TryPost sem registro no Analytics, que o comando liga a ele.
-- `posts_imported`: posts importados que existem agora. É a contagem atual; a
- importação é assíncrona, então o número cresce enquanto as filas drenam.
-- `historical_identity_unrecoverable`: publicações antigas sem canal associado, cuja
- identidade não dá para recuperar e que por isso ficam fora do Analytics.
-
-## 6. Depois do deploy
-
-- A auditoria da mídia já roda no fim do `release:trypost-2` (esperados ×
- inesperados, seção 3). Se algum workspace ficou para a fila, rode o
- `release:trypost-2 --force` de novo quando os `AdoptWorkspaceLibraryJob` zerarem
- (o backfill não é despachado de novo).
-- Confira em alguns canais que Enviados e o calendário mostram os posts importados,
- com mídia e métricas.
-- **Limite do X por conta.** Contas do X com assinatura paga (Basic, Premium,
- PremiumPlus) ou verificadas como empresa passam a ter 25000 caracteres. O plano
- de cada conta é lido na próxima verificação da conexão (diária) ou ao reconectar;
- até lá, a conta segue com 280. Para valer na hora:
- `php artisan social:check-connections` (opcional).
-- **Título do YouTube.** Sem título preenchido, o título é a primeira linha não
- vazia do texto (sem `<` e `>`, até 100 caracteres). Ele não ganha mais ` #Shorts`
- no fim e não é mais cortado no primeiro `.`.
-
-### Canais desconectados
-
-- Rodar `php artisan posts:purge-orphaned` de novo deve imprimir `0 orphaned post(s) deleted, 0 orphaned target(s) removed from posts on other channels.`
-- Desconectar um canal apaga todos os posts dele, sem disparar o webhook `post.deleted`
- para cada post; reconectar a mesma conta importa de novo os posts recentes.
-
-### Aprovação de posts
-
-- Em Configurações → Membros, confira que quem era Viewer aparece como
- "Precisa de aprovação" e quem era Admin aparece como Admin.
-- Com um membro que precisa de aprovação: crie um post na fila e confira que ele
- aparece na aba Aprovações (e não na Fila), que o e-mail chega para os
- aprovadores e que "Adicionar à fila" coloca o post no próximo horário.
-- `posts:process-scheduled` nunca publica `pending_approval`; nada a configurar.
-- Em uma automação de repurpose criada por um membro que precisa de aprovação, o
- item continua marcado como Publicado enquanto os posts dele aguardam aprovação:
- o item representa o envio à fila, não a publicação nas redes.
-
-## 7. Se precisar voltar atrás
-
-Apagar só os posts importados (origem `network`) e as mídias deles. O Analytics fica
-intacto: as publicações apenas voltam a ficar sem ligação.
-
-Antes, defina `EXTERNAL_POSTS_IMPORT_LIMIT=0` e rode `php artisan config:clear`;
-sem isso a próxima busca importa os posts de novo.
-
-```bash
-php artisan posts:purge-imported # já existe
-php artisan posts:purge-imported --workspace=
-```
-
-### Aprovação de posts: papéis
-
-O `migrate:rollback` das migrations de papéis recria a coluna `role` em
-`user_workspace` e em `invites` e preenche pelas flags: `is_admin` →
-Admin; `requires_approval` → Viewer; os demais → Member. O dono da conta volta
-como Admin. Posts em `pending_approval` não existem no esquema antigo: antes de
-voltar atrás, aprove ou rejeite todos (ou mova-os para rascunho), senão ficam com
-um status desconhecido.
-
-As outras migrations da aprovação também voltam atrás sem perda fora delas:
-
-- `add_approval_requested_by_to_posts_table`: o `down()` apaga a chave
- estrangeira e a coluna `approval_requested_by`.
-- `add_approval_columns_to_posts_table`: o `down()` apaga `approved_by` (com a
- chave estrangeira), `approval_requested_at`, `approved_at` e
- `approval_queue_position`. Quem aprovou e quando se perde.
-- `add_collaboration_to_notification_preferences_table`: o `down()` apaga a
- preferência `collaboration`; os e-mails de aprovação deixam de existir junto com
- o código.
-
-## 8. Teste manual antes do release
-
-- **X:** uma sincronização real com uma conta de verdade, conferindo os campos
- `note_tweet` (texto longo) e as variantes de mídia.
-- **Threads:** um carrossel, conferindo a expansão de `children{}`.
-- **Pinterest:** um pin, conferindo a imagem 1200x e o `video_url`.
-
-## 9. Comunicação
-
-Changelog, docs.trypost.it, instruções do MCP e e-mail do release:
-`MEDIA_INTEGRATIONS_SETUP.md` §7.6. Somar a isso:
-
-- Enviados e o calendário passam a mostrar também os posts feitos direto nas redes
- (últimos 50 por canal; no X, último mês).
-- A API e o MCP ganham o campo `origin` (`trypost` | `network`) nos posts.
-- Fuso horário: quem já tinha conta fica em **UTC** (a produção nunca guardou fuso), e os
- canais existentes ganham a grade de horários em UTC. Isso é intencional: no changelog e no
- e-mail do release, peça para cada um conferir o fuso em Configurações → Preferências e o fuso
- de cada canal em Configurações do canal. Cadastros novos já detectam o fuso do navegador.
-
-Aprovação de posts (mudança que quebra compatibilidade para quem lê o status):
-
-- Novo status `pending_approval` em posts na API e no MCP. Posts criados ou
- editados por um membro que precisa de aprovação terminam nesse status em vez de
- `scheduled` / `publishing`.
-- Novos endpoints `POST /api/posts/{post}/approve` (aceita `scheduled_at` ou
- `publish_now`) e `POST /api/posts/{post}/reject`; novas tools MCP
- `approve-post-tool` e `reject-post-tool`; `list-posts-tool` filtra
- `pending_approval`.
-- Os papéis Admin / Member / Viewer deixam de existir: a API e o MCP não expõem
- membros nem convites, então só a documentação e o changelog mudam.
-- Atualizar docs.trypost.it (membros e aprovações, API de posts) e o changelog.
-
-## 10. Comandos avulsos (opcional/manual)
-
-Os comandos que o `release:trypost-2` usa, e os de diagnóstico e de reversão deste
-release, ficam em `app/Console/Commands/Scripts/`. Só servem para rodar um passo
-isolado, ensaiar ou investigar; o deploy normal não precisa deles.
-
-```bash
-php artisan posts:audit-legacy [--strict] # contagens dos posts antigos, só leitura
-php artisan posts:purge-orphaned [--workspace=] # passo 1
-php artisan posts:split-legacy-active # passo 2
-php artisan media:adopt-library --dry-run # o que a adoção faria, por workspace
-php artisan media:adopt-library --force # adoção pela fila (AdoptWorkspaceLibraryJob), em vez do passo 3
-php artisan analytics:backfill-existing [--workspace=] [--include-unsubscribed] [--platforms=instagram,facebook] [--chunk=100] [--delay=0] # passo 4
-php artisan media:audit [--json] # passo 6
-php artisan posts:purge-imported [--workspace=] # reversão (seção 7)
-```
-
-Exemplos do backfill isolado:
-
-```bash
-# Um workspace só (bom para testar primeiro)
-php artisan analytics:backfill-existing --workspace=
-
-# Só algumas redes (ex.: deixar o X, que cobra por leitura, para depois)
-php artisan analytics:backfill-existing --platforms=instagram,instagram-facebook,facebook,threads
-php artisan analytics:backfill-existing --platforms=x --chunk=10 --delay=600
-```
-
-`analytics:dispatch-publication-discovery` (passo 5) não é avulso: o scheduler usa,
-então fica em `app/Console/Commands/Analytics/`.
-**Depois do release, a pasta `app/Console/Commands/Scripts/` pode ser apagada
-inteira** (com os testes dos comandos dela e as menções em
-`docker/entrypoint.sh`, `README.md` e `MEDIA_INTEGRATIONS_SETUP.md`), na mesma
-limpeza da issue #376 (fallback do `PostAtRisk`).
+## 3. Depois do deploy
+
+- Horizon sem jobs falhos novos.
+- Um post de teste publicado em cada rede conectada no workspace de testes.
+- Um webhook de teste (`post.published`) e o email de "publicado".
+- Os posts agendados para os minutos da janela saíram.
+- `post_platforms` não existe mais e `analytics_publications.post_platform_id` foi
+ removida.
+
+### Se algo falhar
+
+- **Antes do `up`** (migration, build ou teste rápido): ou corrija para frente com
+ o app em manutenção, ou apague a linha da migration que falhou em `migrations` e
+ volte o release; as migrations podem rodar de novo (a cópia é idempotente e limpa
+ cópias de destinos que sumiram).
+- **Depois do `up`**: só correção para frente. O código novo grava a publicação só
+ em `posts`; voltar o código antigo republicaria posts que já saíram.
+
+## 4. Mudança de contrato (API, MCP, webhooks)
+
+Muda de uma vez, sem formato de compatibilidade:
+
+- **Objeto Post** (API `GET/POST/PUT /posts`, tools de post do MCP): sem
+ `platforms[]`. No topo: `publish_status`, `social_account`, `platform`,
+ `content_type`, `meta`, `platform_url`, `error_message`, `display_name`,
+ `display_username`, `display_avatar`.
+- **Criar post** (API `POST /posts`, `create-post-tool`): `social_account_id`,
+ `content_type` e `meta` no topo. Enviar `platforms` é erro de validação.
+- **Editar post** (API `PUT /posts/{id}`, `update-post-tool`): `content_type` e
+ `meta` no topo; `platforms` é recusado.
+- **Erros de validação**: `meta.*` e `content_type` (antes `platforms.0.*`); nos
+ lotes, `destinations.{i}.meta.*`.
+- **Status**: `partially_published` deixa de existir; o resultado na rede fica em
+ `publish_status` (`pending`, `publishing`, `retrying`, `pending_review`,
+ `published`, `failed`, `rejected`).
+- **Preview** (API e `preview-post-tool`) e **métricas** (API e
+ `get-post-metrics-tool`): um objeto só, sem `platforms[]` nem `post_platform_id`.
+- **Analytics**: publicações e relatório trazem `post_id` (sem `post_platform_id`).
+- **Repurpose**: cada post do item traz `platform` e `publish_status`.
+- **Webhooks**: o payload `post.*` traz os campos do canal no topo (incluindo
+ `social_account_id` e `platform_post_id`, sem `error_context`). O evento
+ `post.partially_published` foi removido; webhooks que o assinavam passam a
+ assinar `post.published` e `post.failed`. Entregas que já estavam na fila e o
+ replay de logs antigos reenviam o payload no formato antigo.
+
+Páginas da docs.trypost.it a atualizar: objeto Post e endpoints (create, update,
+list, show, preview, attach media), erros de validação, status, Webhooks (payload e
+lista de eventos), Repurpose items, métricas do post, publicações e relatório de
+Analytics, e a página do MCP.
diff --git a/README.md b/README.md
index 95db5dd70..37b852f0b 100644
--- a/README.md
+++ b/README.md
@@ -86,7 +86,8 @@ Posts publish natively through each platform's official API.
Self-hosting media maintenance:
-- `php artisan release:trypost-2 --force` runs the one-off data steps of the 2.0 release once, after `php artisan migrate`: it splits multi-channel posts, purges posts of channels disconnected earlier, copies the old media library onto the posts and ideas that use it (then deletes the library) and queues the analytics backfill. Run it with a queue worker running. The individual commands live in `app/Console/Commands/Scripts/`.
+- Upgrading from v1.x: upgrade to v2.0.0 first and run `php artisan release:trypost-2 --force` there (with a queue worker running). Later versions merge each post's channel into the post and no longer ship that command; their first migration stops and explains what is left if you skip v2.0.0.
+- Before upgrading past v2.0.0, stop the scheduler and let every queue drain (including delayed jobs), so no job queued by the old version is left for the new one.
- `php artisan media:prune-uploads` deletes unused uploads older than `MEDIA_UPLOAD_RETENTION_HOURS` (24 by default) and stale publish crops. The scheduler runs it hourly.
- `php artisan posts:prune-history` deletes published posts, and their media, older than `POST_HISTORY_RETENTION_DAYS` (730 by default). The scheduler runs it daily; analytics are kept.
diff --git a/app/Actions/Analytics/BuildChannelMetricSeries.php b/app/Actions/Analytics/BuildChannelMetricSeries.php
index 09303e587..1388cb562 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 69b07d30e..b882eb5e7 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 79f72ee4e..9ff321b1c 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 ee49ad844..d8b85149b 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 f219eb292..d8d6b6aa9 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 e424bee2e..f9a6ec6a8 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 4b190b5b5..8606603d8 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,25 +49,27 @@ 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. Their errors
+ * are keyed under `$metaErrorKey` (`meta`, or `destinations.{n}.meta` in a
+ * batch), whichever list the root media came from.
*
* @param list> $items
* @param list> $legacyItems
* @return list>
*/
- public static function execute(Post|Idea $owner, array $items, MediaCopyBatch $batch, string $errorKey = 'media', array $legacyItems = []): array
+ public static function execute(Post|Idea $owner, array $items, MediaCopyBatch $batch, string $errorKey = 'media', array $legacyItems = [], string $metaErrorKey = 'meta'): array
{
$ownerColumn = $owner instanceof Post ? 'post_id' : 'idea_id';
$locked = $owner::query()->whereKey($owner->getKey())->lockForUpdate()->firstOrFail();
$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();
- $ids = self::column([...$items, ...$replyItems], 'id');
- $uploadTokens = self::column([...$items, ...$replyItems], 'upload_token');
+ $replies = $locked instanceof Post ? ThreadReplies::of($locked->meta) : [];
+ $submitted = [...$items, ...self::replyMedia($replies)];
+ $ids = self::column($submitted, 'id');
+ $uploadTokens = self::column($submitted, 'upload_token');
self::lockRows($workspace->id, $ownerColumn, $owner->getKey(), $ids, $uploadTokens);
$rows = ResolveWorkspaceMedia::execute($workspace, $ids, lockForUpdate: true);
@@ -116,37 +116,23 @@ public static function execute(Post|Idea $owner, array $items, MediaCopyBatch $b
};
$final = $sync($items, $errorKey);
- $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();
- }
+ $replies = collect($replies)
+ ->map(fn (array $reply, int $index): array => [
+ ...$reply,
+ 'media' => $sync(self::withoutRepeats($reply['media']), "{$metaErrorKey}.thread_replies.{$index}.media"),
+ ])
+ ->all();
+ $kept = self::column([...$final, ...self::replyMedia($replies)], 'id');
DeleteOwnedMedia::forRows($owner->ownedMedia()->whereNotIn('id', $kept)->pluck('id')->all());
- $owner->forceFill(['media' => $final])->save();
+ $owner->update($replies === [] ? ['media' => $final] : [
+ 'media' => $final,
+ 'meta' => [...($locked->meta ?? []), 'thread_replies' => $replies],
+ ]);
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
@@ -298,6 +284,15 @@ private static function withEdits(array $owned, array $submitted): array
return $owned;
}
+ /**
+ * @param list>}> $replies
+ * @return list>
+ */
+ private static function replyMedia(array $replies): array
+ {
+ return array_merge([], ...array_column($replies, 'media'));
+ }
+
/**
* @param list> $items
* @return list
diff --git a/app/Actions/Post/AppendPostMedia.php b/app/Actions/Post/AppendPostMedia.php
index 834df2c12..2b1b4f020 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/Approval/NotifyApprovalDecision.php b/app/Actions/Post/Approval/NotifyApprovalDecision.php
index 567087202..2aa863fc1 100644
--- a/app/Actions/Post/Approval/NotifyApprovalDecision.php
+++ b/app/Actions/Post/Approval/NotifyApprovalDecision.php
@@ -4,45 +4,33 @@
namespace App\Actions\Post\Approval;
+use App\Enums\Notification\Type;
use App\Enums\Post\ApprovalDecision;
-use App\Jobs\Post\SendApprovalDecisionEmail;
+use App\Jobs\SendNotification;
+use App\Mail\PostApproved;
+use App\Mail\PostRejected;
use App\Models\Post;
use App\Models\User;
-use Illuminate\Contracts\Cache\LockTimeoutException;
-use Illuminate\Support\Facades\Cache;
-use Illuminate\Support\Facades\DB;
final class NotifyApprovalDecision
{
- public const GROUPING_SECONDS = 60;
-
/**
- * Collects a decision for the member who asked for approval (see
- * Post::approvalRequester()); the cards of one request (same post_group_id)
- * decided within the grouping window share one email.
+ * Emails the member who asked for approval (see Post::approvalRequester())
+ * about one post, once the decision commits. Deciding your own request
+ * sends nothing.
*/
public static function execute(Post $post, ApprovalDecision $decision, User $approver, ?User $requester): void
{
- if ($requester === null || $requester->id === $approver->id) {
+ if (blank($requester) || $requester->id === $approver->id) {
return;
}
- $group = $post->post_group_id ?? $post->id;
- $key = "post-approval-decision:{$group}:{$decision->value}:{$approver->id}:{$requester->id}";
-
- DB::afterCommit(function () use ($key, $post, $requester, $approver, $decision): void {
- try {
- Cache::lock("{$key}:lock", 10)->block(5, function () use ($key, $post): void {
- Cache::put($key, array_values(array_unique([...Cache::get($key, []), $post->id])), now()->addDay());
- });
- } catch (LockTimeoutException $exception) {
- report($exception);
-
- return;
- }
-
- SendApprovalDecisionEmail::dispatch($key, $requester, $approver, $decision)
- ->delay(now()->addSeconds(self::GROUPING_SECONDS));
- });
+ SendNotification::dispatch(
+ $requester,
+ Type::Collaboration,
+ $decision === ApprovalDecision::Approved
+ ? new PostApproved($post->id, $approver, $requester)
+ : new PostRejected($post->id, $approver),
+ )->afterCommit();
}
}
diff --git a/app/Actions/Post/Approval/NotifyApprovalRequested.php b/app/Actions/Post/Approval/NotifyApprovalRequested.php
index 584d5ea93..b0682ca2e 100644
--- a/app/Actions/Post/Approval/NotifyApprovalRequested.php
+++ b/app/Actions/Post/Approval/NotifyApprovalRequested.php
@@ -14,8 +14,9 @@
final class NotifyApprovalRequested
{
/**
- * One email per approver for one request; the posts created together for
- * several channels are listed in the same email. Never emails the requester.
+ * One email per post for every approver: posts created together for
+ * several channels are separate posts, each approved on its own. Never
+ * emails the requester.
*
* @param Collection $posts
*/
@@ -23,18 +24,16 @@ public static function execute(Collection $posts, User $requester): void
{
$workspace = $posts->first()?->workspace;
- if ($workspace === null) {
+ if (blank($workspace)) {
return;
}
- $postIds = $posts->pluck('id')->values()->all();
+ $approvers = $workspace->approvers()->reject(fn (User $approver): bool => $approver->id === $requester->id);
- $workspace->approvers()
- ->reject(fn (User $approver): bool => $approver->id === $requester->id)
- ->each(fn (User $approver) => SendNotification::dispatch(
- $approver,
- Type::Collaboration,
- new PostApprovalRequested($postIds, $requester, $approver),
- )->afterCommit());
+ $posts->each(fn (Post $post) => $approvers->each(fn (User $approver) => SendNotification::dispatch(
+ $approver,
+ Type::Collaboration,
+ new PostApprovalRequested($post->id, $requester, $approver),
+ )->afterCommit()));
}
}
diff --git a/app/Actions/Post/BakeLegacyAspectRatioCrops.php b/app/Actions/Post/BakeLegacyAspectRatioCrops.php
deleted file mode 100644
index 35e765fa5..000000000
--- 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 9154d3d2e..707235cfa 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 b9763fb03..bd6aba380 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(filled($channel), 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 (filled($account)) {
$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 7779b3df4..2dff0ed42 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'] ?? []),
]);
@@ -76,6 +72,7 @@ public static function execute(Workspace $workspace, User $user, array $destinat
$batch,
data_get($destination, 'media_error_key', 'media'),
data_get($destination, 'legacy_media', []),
+ data_get($destination, 'meta_error_key', 'meta'),
);
if ($destination['label_ids'] !== []) {
diff --git a/app/Actions/Post/DeleteChannelPosts.php b/app/Actions/Post/DeleteChannelPosts.php
index 631bce726..bd1075380 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'])
+ ->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 b1f058015..8603827d1 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 4acce903e..60d2184f8 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 (blank($original->socialAccount)) {
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 fb42cf8d9..97c2f346b 100644
--- a/app/Actions/Post/FinalizePostPublication.php
+++ b/app/Actions/Post/FinalizePostPublication.php
@@ -5,18 +5,18 @@
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 App\Models\User;
use Illuminate\Support\Facades\DB;
/**
- * Settles a post once every enabled target has reached a terminal state, and
- * notifies the owner once. Shared by PublishToSocialPlatform, PublishPost::failed,
+ * Settles a post once its publication has reached a terminal state, and
+ * notifies the workspace once. Shared by PublishToSocialPlatform, PublishPost::failed,
* RecoverStuckPosts, and ReconcileGoogleBusinessPost.
*/
class FinalizePostPublication
@@ -26,7 +26,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', 'socialAccount'])
->whereKey($post->id)
->lockForUpdate()
->first();
@@ -35,32 +35,25 @@ public function handle(Post $post): void
return null;
}
- $targets = $post->postPlatforms->where('enabled', true);
-
- if ($targets->isEmpty()) {
+ if (! $post->hasChannel() && $post->publish_status->isInFlight()) {
if ($post->status !== PostStatus::Publishing) {
return null;
}
+ $post->markPublicationFailed(__('posts.errors.choose_channel'));
$post->markAsFailed();
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();
}
@@ -77,18 +70,17 @@ public function handle(Post $post): void
$this->notify($outcome['post'], $outcome['successful']);
}
+ /**
+ * Emails every member of the workspace (the owner is always one); each
+ * one's notification preferences decide whether it is sent.
+ */
private function notify(Post $post, bool $successful): void
{
- $owner = $post->workspace->owner;
-
- if (! $owner) {
- return;
- }
-
- SendNotification::dispatch(
- user: $owner,
- type: $successful ? Type::PostPublished : Type::PostFailed,
- mailable: $successful ? new PostPublished($post) : new PostPublishFailed($post),
- );
+ $post->workspace->members()->get()
+ ->each(fn (User $member) => SendNotification::dispatch(
+ user: $member,
+ type: $successful ? Type::PostPublished : Type::PostFailed,
+ mailable: $successful ? new PostPublished($post) : new PostPublishFailed($post),
+ ));
}
}
diff --git a/app/Actions/Post/HostInlineMedia.php b/app/Actions/Post/HostInlineMedia.php
index 3e9b380e8..fcb28df4e 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, data_get($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_get($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 22e757c43..ccccf9102 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 => blank($publication->post_id) && blank($publication->post_dismissed_at))
->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 (blank($publication) || filled($publication->post_id) || filled($publication->post_dismissed_at)) {
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 06cf5f542..94283fbb3 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 cc2f3a55f..cf3a2c4dd 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 d15b22891..db25f5b2d 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 d635f88b9..969a75e3e 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 7b0031d5d..436b80b85 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 b21128509..f0007bec4 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 85261411c..9ef0bf0f1 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->hasChannel()) {
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 8af576b69..f72c9dc30 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 = filled($post->loadMissing('socialAccount')->socialAccount) ? 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 c01070feb..ee1e4d985 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) {
@@ -262,7 +174,7 @@ private static function updateChannelPost(Workspace $workspace, Post $post, arra
default => $post->schedule_mode ?? ScheduleMode::Custom,
};
$storedStatus = $pending ? PostStatus::PendingApproval : PostStatus::from($status);
- $resolved = PostCompositionValidator::validate($workspace, [
+ $resolved = PostCompositionValidator::forSinglePost(fn (): array => PostCompositionValidator::validate($workspace, [
'status' => $status,
'queue' => $keepsPending ? null : $position?->value,
'content' => array_key_exists('content', $data) ? $data['content'] : $post->content,
@@ -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 ?? []);
+ ], $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 30637a149..0cc1d2eb3 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/ApplyChannelDefaults.php b/app/Actions/SocialAccount/ApplyChannelDefaults.php
index 826c575e1..4a6b594b0 100644
--- a/app/Actions/SocialAccount/ApplyChannelDefaults.php
+++ b/app/Actions/SocialAccount/ApplyChannelDefaults.php
@@ -6,21 +6,16 @@
use App\Models\SocialAccount;
use App\Support\Timezone;
-use Illuminate\Database\Eloquent\Builder;
-use Illuminate\Database\Eloquent\Collection;
/**
* A channel starts with a usable posting setup: a time zone, a
* three-a-week goal and the network's recommended slots. New channels get it
- * when they connect; channels connected before posting schedules existed get
- * it once through `backfill()` (release:trypost-2).
+ * when they connect.
*/
class ApplyChannelDefaults
{
public const int GOAL = 3;
- public const int CHUNK = 100;
-
/**
* Quiet write: no observer, event or PostHog call, and the channel queue
* is not reflowed.
@@ -33,41 +28,4 @@ public static function execute(SocialAccount $account, ?string $timezone): void
'posting_schedule' => app(GeneratePostingSchedule::class)->handle($account->platform, self::GOAL),
])->saveQuietly();
}
-
- /**
- * Channels without a posting schedule, which `backfill()` sets up.
- */
- public static function pending(): int
- {
- return self::withoutSchedule()->count();
- }
-
- /**
- * Gives every channel without a posting schedule the defaults, in its
- * workspace owner's time zone (UTC when the owner has none). A channel
- * that already has a schedule is never touched.
- */
- public static function backfill(): int
- {
- $count = 0;
-
- self::withoutSchedule()
- ->with('workspace.owner')
- ->chunkById(self::CHUNK, function (Collection $accounts) use (&$count): void {
- foreach ($accounts as $account) {
- self::execute($account, $account->workspace?->owner?->timezone);
- $count++;
- }
- });
-
- return $count;
- }
-
- /**
- * @return Builder
- */
- private static function withoutSchedule(): Builder
- {
- return SocialAccount::query()->withoutGlobalScopes()->whereNull('posting_schedule');
- }
}
diff --git a/app/Actions/SocialAccount/CountPostsSentThisWeek.php b/app/Actions/SocialAccount/CountPostsSentThisWeek.php
index 3c75dffc8..bbd848999 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 7d4514768..205cf69f5 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 050a5d9b5..b1623c94b 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 (blank($post)) {
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 271cf0a3d..1feb1f2b5 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'])
+ ->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 11caf182d..709f444ce 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 9f762c898..61b7bc619 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 1e576643d..b6ed0ba73 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 1bd7722ad..6664b7def 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;
@@ -37,110 +34,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 = $post->publish_status->isInFlight()
+ && blank($post->retry_at)
+ && (blank($post->publication_updated_at) || $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 (! $post->publish_status->isFinished()) {
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 || blank($post->submitted_at) || $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 ffcdbcc4a..6bfe0d249 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 && blank(PublishCheckpoint::tiktokPublishId($post->error_context))) {
+ $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 20b0d0b99..000000000
--- 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 17b64e122..000000000
--- 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 1608ac32c..000000000
--- 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 3d72ff78a..000000000
--- 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 b044accfe..000000000
--- 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 a1b8d3ce1..000000000
--- 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 39a9084bb..e6dd2ba54 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 66%
rename from app/Enums/PostPlatform/Status.php
rename to app/Enums/Post/PublishStatus.php
index 3c6789504..d8c5fb9dc 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) {
@@ -28,4 +30,10 @@ public function isClosed(): bool
{
return $this->isFinished() || $this === self::PendingReview;
}
+
+ /** Still to be sent or being sent: pending, publishing or retrying. */
+ public function isInFlight(): bool
+ {
+ return ! $this->isClosed();
+ }
}
diff --git a/app/Enums/Post/Status.php b/app/Enums/Post/Status.php
index 851de05ee..c4afe00ba 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 664b7d57d..6116b1897 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 b4f540c33..51b4f4dc3 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 ca0378e36..a10a43258 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/PlatformController.php b/app/Http/Controllers/Api/PlatformController.php
index 0ec9edf16..d0f2d08f1 100644
--- a/app/Http/Controllers/Api/PlatformController.php
+++ b/app/Http/Controllers/Api/PlatformController.php
@@ -12,7 +12,7 @@ class PlatformController extends Controller
{
/**
* Index of platforms with their valid content_types and publishing
- * constraints — used by clients to build correct `platforms[].content_type`
+ * constraints — used by clients to build correct `content_type`
* payloads for `POST /api/posts` and `PUT /api/posts/{id}`.
*/
public function contentTypes(): JsonResponse
diff --git a/app/Http/Controllers/Api/PostApprovalController.php b/app/Http/Controllers/Api/PostApprovalController.php
index a355d7f29..7fff48fa8 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 99fa0892f..bda9755aa 100644
--- a/app/Http/Controllers/Api/PostController.php
+++ b/app/Http/Controllers/Api/PostController.php
@@ -28,11 +28,13 @@
use App\Models\Media;
use App\Models\Post;
use App\Services\Post\MediaAttacher;
+use App\Support\PostCompositionValidator;
use App\Support\PostStatusRules;
use App\Support\Requests\Post\PostMediaRequestRules;
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 +50,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 +68,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);
}
@@ -78,7 +80,7 @@ public function store(StorePostRequest $request): JsonResponse
$data = HostInlineMedia::forPost($workspace, Post::allowedMediaTypesFor($request->selectedPlatforms()), $data);
- $post = CreatePosts::execute($workspace, $request->user(), [
+ $post = PostCompositionValidator::forSinglePost(fn (): Post => CreatePosts::execute($workspace, $request->user(), [
'status' => $data['status'] ?? 'draft',
'content' => $data['content'] ?? '',
'media' => $data['media'] ?? [],
@@ -87,10 +89,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]],
- ])->sole();
+ '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 +109,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 +134,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 +160,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 +175,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 +184,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 +193,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 +202,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 10a2e3b02..08661e24d 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 485d08d4f..f011c3ab4 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->hasChannel()) {
return $post->status === PostStatus::Draft;
}
- return $targets->count() === 1 && $targets->first()->social_account_id !== null;
+ return true;
}
}
diff --git a/app/Http/Controllers/App/PostController.php b/app/Http/Controllers/App/PostController.php
index af183649c..fc675d0ed 100644
--- a/app/Http/Controllers/App/PostController.php
+++ b/app/Http/Controllers/App/PostController.php
@@ -4,7 +4,6 @@
namespace App\Http\Controllers\App;
-use App\Actions\Analytics\ReadPublicationAnalytics;
use App\Actions\Post\BuildCalendarPageProps;
use App\Actions\Post\CreatePosts;
use App\Actions\Post\DeletePost;
@@ -18,9 +17,7 @@
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;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
@@ -137,17 +134,6 @@ 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
- {
- $this->authorize('view', $post);
-
- if ($postPlatform->post_id !== $post->id) {
- abort(404);
- }
-
- return response()->json(app(ReadPublicationAnalytics::class)->forPlatform($postPlatform));
- }
-
public function edit(Request $request, Post $post): Response|RedirectResponse
{
$workspace = $request->user()->currentWorkspace;
@@ -245,9 +231,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 8a60e92b6..22b2e1626 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 9371e7089..d8289a462 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 337995bbc..21d00894c 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/StorePostRequest.php b/app/Http/Requests/App/Post/StorePostRequest.php
index 3ba079d62..cae1a5043 100644
--- a/app/Http/Requests/App/Post/StorePostRequest.php
+++ b/app/Http/Requests/App/Post/StorePostRequest.php
@@ -4,7 +4,9 @@
namespace App\Http\Requests\App\Post;
+use App\Enums\Post\Status;
use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;
class StorePostRequest extends FormRequest
{
@@ -19,7 +21,7 @@ public function authorize(): bool
public function rules(): array
{
return [
- 'status' => ['required', 'in:draft,scheduled,publishing'],
+ 'status' => ['required', Rule::in([Status::Draft->value, Status::Scheduled->value, Status::Publishing->value])],
'content' => ['sometimes', 'nullable', 'string'],
'media' => ['sometimes', 'array'],
'scheduled_at' => ['nullable', 'date'],
diff --git a/app/Http/Requests/App/Post/UpdatePostRecurrenceRequest.php b/app/Http/Requests/App/Post/UpdatePostRecurrenceRequest.php
index c09862c69..57fb7e7fa 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 b0c2ebb26..22382d3a7 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(filled($target) ? ['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' => [
+ '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 e2fa6abd2..d72664610 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 3995c87c6..000000000
--- 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 44825305a..9bb12ca67 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 ec4bd3938..78929d7d8 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 1ccb4b05b..1b59e4b26 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 8dac2b01c..f653f4b50 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 aa61c9e6a..f13efe779 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 f69fa2ad6..000000000
--- 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 f2bae2e66..981415bfe 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 8f619188d..0578ebbb6 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 9ec65f192..debaac000 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
@@ -139,7 +137,7 @@ private function target(): array
return [null, null, null];
}
- return [$post, $publication, $target->socialAccount];
+ return [$post, $publication, $post->socialAccount];
}
/**
diff --git a/app/Jobs/Post/SendApprovalDecisionEmail.php b/app/Jobs/Post/SendApprovalDecisionEmail.php
deleted file mode 100644
index fc2353753..000000000
--- a/app/Jobs/Post/SendApprovalDecisionEmail.php
+++ /dev/null
@@ -1,62 +0,0 @@
-key;
- }
-
- public function handle(): void
- {
- try {
- $postIds = Cache::lock("{$this->key}:lock", 10)->block(5, fn (): array => Cache::pull($this->key, []));
- } catch (LockTimeoutException) {
- $this->release(5);
-
- return;
- }
-
- if ($postIds === [] || ! Post::query()->whereIn('id', $postIds)->exists()) {
- return;
- }
-
- SendNotification::dispatch(
- $this->requester,
- Type::Collaboration,
- $this->decision === ApprovalDecision::Approved
- ? new PostApproved($postIds, $this->approver, $this->requester)
- : new PostRejected($postIds, $this->approver),
- );
- }
-}
diff --git a/app/Jobs/PostHog/SyncAccountPublishingActivity.php b/app/Jobs/PostHog/SyncAccountPublishingActivity.php
index 749a79213..ad0369a57 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 0ac71b320..ea788f2a9 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 cdeef1867..7692c9f55 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 6a57db1ac..ef7a863f4 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 a18239291..7f452da4c 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/SendNotification.php b/app/Jobs/SendNotification.php
index 7174ab88e..0b912d642 100644
--- a/app/Jobs/SendNotification.php
+++ b/app/Jobs/SendNotification.php
@@ -7,6 +7,8 @@
use App\Enums\Notification\Channel;
use App\Enums\Notification\Type;
use App\Mail\PostApprovalRequested;
+use App\Mail\PostApproved;
+use App\Mail\PostRejected;
use App\Models\Post;
use App\Models\User;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -36,7 +38,7 @@ public function __construct(
public function handle(): void
{
- if (! $this->deliversEmail || ! $this->user->wantsEmailFor($this->type) || ! $this->keepsPendingApprovalPosts()) {
+ if (! $this->deliversEmail || ! $this->user->wantsEmailFor($this->type) || ! $this->stillAboutItsPost()) {
return;
}
@@ -59,19 +61,16 @@ public function __unserialize(array $values): void
}
/**
- * An approval request lists only its posts still waiting for approval, and
- * is not sent once every one was approved, rejected or deleted.
+ * An approval request is not sent once its post was approved, rejected or
+ * deleted, and a decision is not sent once its post was deleted.
*/
- private function keepsPendingApprovalPosts(): bool
+ private function stillAboutItsPost(): bool
{
- if (! $this->mailable instanceof PostApprovalRequested) {
- return true;
- }
-
- $pendingIds = Post::query()->whereIn('id', $this->mailable->postIds)->pendingApproval()->pluck('id')->all();
- $this->mailable->postIds = array_values(array_intersect($this->mailable->postIds, $pendingIds));
-
- return $this->mailable->postIds !== [];
+ return match (true) {
+ $this->mailable instanceof PostApprovalRequested => Post::query()->whereKey($this->mailable->postId)->pendingApproval()->exists(),
+ $this->mailable instanceof PostApproved, $this->mailable instanceof PostRejected => Post::query()->whereKey($this->mailable->postId)->exists(),
+ default => true,
+ };
}
public function failed(Throwable $exception): void
diff --git a/app/Jobs/VerifyUpcomingPostConnections.php b/app/Jobs/VerifyUpcomingPostConnections.php
index c64f53e68..4318a3352 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/PostApprovalRequested.php b/app/Mail/PostApprovalRequested.php
index 72a8d20f6..79b959eaf 100644
--- a/app/Mail/PostApprovalRequested.php
+++ b/app/Mail/PostApprovalRequested.php
@@ -6,7 +6,7 @@
use App\Enums\Post\ScheduleMode;
use App\Models\User;
-use App\Support\Mail\ApprovalEmailPosts;
+use App\Support\Mail\ApprovalEmailPost;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
@@ -18,11 +18,8 @@ class PostApprovalRequested extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
- /**
- * @param list $postIds
- */
public function __construct(
- public array $postIds,
+ public string $postId,
public User $requester,
public User $recipient,
) {}
@@ -36,9 +33,8 @@ public function envelope(): Envelope
public function content(): Content
{
- $posts = ApprovalEmailPosts::load($this->postIds);
- $first = $posts->first();
- $workspaceName = (string) $first?->workspace?->name;
+ $post = ApprovalEmailPost::find($this->postId);
+ $workspaceName = (string) $post?->workspace?->name;
return new Content(
view: 'mail.post-approval-requested',
@@ -48,10 +44,10 @@ public function content(): Content
'requesterName' => $this->requester->name,
'requesterEmail' => $this->requester->email,
'workspaceName' => $workspaceName,
- 'postExcerpt' => ApprovalEmailPosts::excerpt($first),
- 'channels' => ApprovalEmailPosts::channels($posts),
- 'queued' => $first?->schedule_mode === ScheduleMode::Queue && $first->scheduled_at === null,
- 'requestedTime' => ApprovalEmailPosts::time($first?->scheduled_at, $this->recipient),
+ 'postExcerpt' => ApprovalEmailPost::excerpt($post),
+ 'channel' => ApprovalEmailPost::channel($post),
+ 'queued' => $post?->schedule_mode === ScheduleMode::Queue && blank($post->scheduled_at),
+ 'requestedTime' => ApprovalEmailPost::time($post?->scheduled_at, $this->recipient),
'url' => route('app.posts.index', ['tab' => 'approvals']),
],
);
diff --git a/app/Mail/PostApproved.php b/app/Mail/PostApproved.php
index 692cfd00e..ff43b6ddc 100644
--- a/app/Mail/PostApproved.php
+++ b/app/Mail/PostApproved.php
@@ -5,7 +5,7 @@
namespace App\Mail;
use App\Models\User;
-use App\Support\Mail\ApprovalEmailPosts;
+use App\Support\Mail\ApprovalEmailPost;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
@@ -17,11 +17,8 @@ class PostApproved extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
- /**
- * @param list $postIds
- */
public function __construct(
- public array $postIds,
+ public string $postId,
public User $approver,
public User $recipient,
) {}
@@ -35,9 +32,8 @@ public function envelope(): Envelope
public function content(): Content
{
- $posts = ApprovalEmailPosts::load($this->postIds);
- $workspaceName = (string) $posts->first()?->workspace?->name;
- $goesOut = ApprovalEmailPosts::goesOut($posts, $this->recipient);
+ $post = ApprovalEmailPost::find($this->postId);
+ $workspaceName = (string) $post?->workspace?->name;
return new Content(
view: 'mail.post-approved',
@@ -46,9 +42,8 @@ public function content(): Content
'previewText' => __('mail.post_approved.preview', ['name' => $this->approver->name, 'workspace' => $workspaceName]),
'approverName' => $this->approver->name,
'workspaceName' => $workspaceName,
- 'channels' => ApprovalEmailPosts::channels($posts),
- 'goesOutAt' => data_get($goesOut, 'at'),
- 'goesOutPerChannel' => data_get($goesOut, 'perChannel'),
+ 'channel' => ApprovalEmailPost::channel($post),
+ 'goesOutAt' => ApprovalEmailPost::goesOutAt($post, $this->recipient),
'url' => route('app.posts.index', ['tab' => 'queue']),
],
);
diff --git a/app/Mail/PostAtRisk.php b/app/Mail/PostAtRisk.php
index a6e8dc852..c9dde06a1 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 3edb2abd6..718bd7140 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(),
+ 'channel' => $post->loadMissing('socialAccount')->hasDestination() ? $post : null,
'url' => route('app.posts.edit', ['post' => $post, 'comment' => $this->note->id]),
],
);
diff --git a/app/Mail/PostPublishFailed.php b/app/Mail/PostPublishFailed.php
index a8bb79006..4f9160cdf 100644
--- a/app/Mail/PostPublishFailed.php
+++ b/app/Mail/PostPublishFailed.php
@@ -4,7 +4,6 @@
namespace App\Mail;
-use App\Enums\PostPlatform\Status;
use App\Models\Post;
use App\Support\Mail\PostPreview;
use Illuminate\Bus\Queueable;
@@ -31,11 +30,7 @@ public function envelope(): Envelope
public function content(): Content
{
- $postPlatform = $this->post->postPlatforms()
- ->with('socialAccount')
- ->enabled()
- ->where('status', Status::Failed)
- ->first();
+ $this->post->loadMissing('socialAccount');
return new Content(
view: 'mail.post-publish-failed',
@@ -43,11 +38,11 @@ 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,
- ] : null,
+ 'publication' => [
+ 'accountName' => $this->post->display_name,
+ 'platform' => $this->post->platform,
+ 'error' => $this->post->error_message,
+ ],
'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 04d43c862..2389f5d54 100644
--- a/app/Mail/PostPublished.php
+++ b/app/Mail/PostPublished.php
@@ -30,11 +30,7 @@ public function envelope(): Envelope
public function content(): Content
{
- $postPlatform = $this->post->postPlatforms()
- ->with('socialAccount')
- ->enabled()
- ->published()
- ->first();
+ $this->post->loadMissing('socialAccount');
return new Content(
view: 'mail.post-published',
@@ -42,12 +38,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,
- ] : null,
+ 'publication' => [
+ 'accountName' => $this->post->display_name,
+ 'platform' => $this->post->platform,
+ ],
'postPreview' => PostPreview::from($this->post),
- 'publishedUrl' => $postPlatform?->platform_url,
+ 'publishedUrl' => $this->post->platform_url,
'url' => route('app.posts.edit', $this->post),
],
);
diff --git a/app/Mail/PostRejected.php b/app/Mail/PostRejected.php
index 374224cc6..7fca9c403 100644
--- a/app/Mail/PostRejected.php
+++ b/app/Mail/PostRejected.php
@@ -5,7 +5,7 @@
namespace App\Mail;
use App\Models\User;
-use App\Support\Mail\ApprovalEmailPosts;
+use App\Support\Mail\ApprovalEmailPost;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
@@ -17,11 +17,8 @@ class PostRejected extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
- /**
- * @param list $postIds
- */
public function __construct(
- public array $postIds,
+ public string $postId,
public User $approver,
) {}
@@ -34,8 +31,8 @@ public function envelope(): Envelope
public function content(): Content
{
- $posts = ApprovalEmailPosts::load($this->postIds);
- $workspaceName = (string) $posts->first()?->workspace?->name;
+ $post = ApprovalEmailPost::find($this->postId);
+ $workspaceName = (string) $post?->workspace?->name;
return new Content(
view: 'mail.post-rejected',
@@ -44,7 +41,7 @@ public function content(): Content
'previewText' => __('mail.post_rejected.preview', ['name' => $this->approver->name]),
'approverName' => $this->approver->name,
'workspaceName' => $workspaceName,
- 'channels' => ApprovalEmailPosts::channels($posts),
+ 'channel' => ApprovalEmailPost::channel($post),
'url' => route('app.posts.index', ['tab' => 'drafts']),
],
);
diff --git a/app/Mcp/Tools/Post/ApprovePostTool.php b/app/Mcp/Tools/Post/ApprovePostTool.php
index 67b1b55e4..9d01730b2 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 a1107d100..30a365b99 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 7ffbf5078..ca8a606eb 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 5f538edbb..20584ee79 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 e4574f063..a0df84d9a 100644
--- a/app/Mcp/Tools/Post/CreatePostTool.php
+++ b/app/Mcp/Tools/Post/CreatePostTool.php
@@ -17,10 +17,12 @@
use App\Models\Post;
use App\Models\SocialAccount;
use App\Models\Workspace;
+use App\Support\PostCompositionValidator;
use App\Support\PostPlatformMetaRules;
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;
@@ -54,7 +56,7 @@ public function handle(Request $request): Response|ResponseFactory
$validated = HostInlineMedia::forPost($workspace, Post::allowedMediaTypesFor($platforms), $validated);
try {
- $post = CreatePosts::execute($workspace, $request->user(), [
+ $post = PostCompositionValidator::forSinglePost(fn (): Post => CreatePosts::execute($workspace, $request->user(), [
'status' => $validated['status'] ?? Status::Draft->value,
'content' => $validated['content'] ?? '',
'media' => $validated['media'] ?? [],
@@ -63,13 +65,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]],
- ])->sole();
+ '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 +90,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 5cc50282e..facb23311 100644
--- a/app/Mcp/Tools/Post/CreatePostsTool.php
+++ b/app/Mcp/Tools/Post/CreatePostsTool.php
@@ -8,6 +8,7 @@
use App\Actions\Post\HostInlineMedia;
use App\Enums\Post\CreatedVia;
use App\Enums\Post\QueuePosition;
+use App\Enums\Post\Status;
use App\Exceptions\Post\QueueBusyException;
use App\Http\Resources\Api\PostResource;
use App\Mcp\Concerns\AuthorizesMcpTool;
@@ -52,7 +53,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(),
@@ -62,7 +63,7 @@ public function handle(Request $request): Response|ResponseFactory
public function schema(JsonSchema $schema): array
{
return [
- 'status' => $schema->string()->enum(['draft', 'scheduled', 'publishing'])->required()->description('draft keeps the posts editable, scheduled schedules them at scheduled_at or in the queue, publishing publishes them now.'),
+ 'status' => $schema->string()->enum([Status::Draft->value, Status::Scheduled->value, Status::Publishing->value])->required()->description('draft keeps the posts editable, scheduled schedules them at scheduled_at or in the queue, publishing publishes them now.'),
'content' => $schema->string()->description('Shared text; each destination may override it. The post text, in plain text; use \n for line breaks. When link defusing is on, an X post publishes its links non-clickable (example(.)com); preview-post-tool shows the exact text each network gets. The text must fit the account limit (max_content_length in list-social-accounts-tool; on X, 25000 for an account with long_posts, else 280), Instagram takes at most 5 hashtags, and stories publish no text.'),
'media' => $this->mediaSchema($schema, 'Media shared by every destination by default.'),
'scheduled_at' => $schema->string()->description('ISO 8601 datetime in the future and before 2038-01-19, e.g. 2026-05-10T15:30:00Z; without an offset it is read as UTC. Times in responses are UTC (Y-m-d H:i:s). Required when status is scheduled without queue.'),
diff --git a/app/Mcp/Tools/Post/DeletePostTool.php b/app/Mcp/Tools/Post/DeletePostTool.php
index 299f516ec..7d7d21043 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 5571b0002..ce28a5528 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 ba3790941..3c43057e9 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 a06cc7e8f..d6df4d505 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(),
@@ -89,8 +89,8 @@ public function schema(JsonSchema $schema): array
{
return [
'status' => $schema->string()
- ->enum(['draft', 'scheduled', 'published', 'failed', 'pending_approval'])
- ->description('Filter by status. "published" includes partially-published posts.'),
+ ->enum([Status::Draft->value, Status::Scheduled->value, Status::Published->value, Status::Failed->value, Status::PendingApproval->value])
+ ->description('Filter by status.'),
'search' => $schema->string()->description('Case-insensitive substring match against the post content.'),
'channels' => $schema->array()->items($schema->string())->description('Only posts on one of these social account IDs (list-social-accounts-tool).'),
'labels' => $schema->array()->items($schema->string())->description('Only posts with one of these label IDs.'),
diff --git a/app/Mcp/Tools/Post/PreviewPostTool.php b/app/Mcp/Tools/Post/PreviewPostTool.php
index 92f098745..db4f4fcc2 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 b5261aa70..0b586842d 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 edc0bb562..8eb4024dc 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 3e8e2bd8d..4c9aa25e1 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 47c37d108..e75bafadd 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/GetTikTokCreatorInfoTool.php b/app/Mcp/Tools/SocialAccount/GetTikTokCreatorInfoTool.php
index 9e81cc655..78ba279f5 100644
--- a/app/Mcp/Tools/SocialAccount/GetTikTokCreatorInfoTool.php
+++ b/app/Mcp/Tools/SocialAccount/GetTikTokCreatorInfoTool.php
@@ -22,7 +22,7 @@
#[IsReadOnly]
#[Name('get-tiktok-creator-info-tool')]
-#[Description('Read what TikTok lets a connected TikTok account publish right now: privacy_level_options (the only privacy_level values allowed for this creator), comment_disabled / duet_disabled / stitch_disabled (when true, allow_comments / allow_duet / allow_stitch must stay false), max_video_post_duration_sec (the longest video TikTok accepts from this creator; it is not checked when you schedule, so compare the video duration yourself), and can_post. Call it before creating or scheduling a TikTok post and pick platforms[].meta.privacy_level from privacy_level_options. can_post false means TikTok banned this creator from posting. When TikTok reports a posting limit (daily cap, quota or rate limit) the tool returns an error instead: scheduled posts retry automatically once it lifts. If TikTok does not answer, the tool returns an error: retry in a moment.')]
+#[Description('Read what TikTok lets a connected TikTok account publish right now: privacy_level_options (the only privacy_level values allowed for this creator), comment_disabled / duet_disabled / stitch_disabled (when true, allow_comments / allow_duet / allow_stitch must stay false), max_video_post_duration_sec (the longest video TikTok accepts from this creator; it is not checked when you schedule, so compare the video duration yourself), and can_post. Call it before creating or scheduling a TikTok post and pick meta.privacy_level (destinations[].meta.privacy_level in create-posts-tool) from privacy_level_options. can_post false means TikTok banned this creator from posting. When TikTok reports a posting limit (daily cap, quota or rate limit) the tool returns an error instead: scheduled posts retry automatically once it lifts. If TikTok does not answer, the tool returns an error: retry in a moment.')]
class GetTikTokCreatorInfoTool extends Tool
{
use AuthorizesMcpTool;
diff --git a/app/Mcp/Tools/SocialAccount/ListDiscordChannelsTool.php b/app/Mcp/Tools/SocialAccount/ListDiscordChannelsTool.php
index 4a4bf3363..616f72fca 100644
--- a/app/Mcp/Tools/SocialAccount/ListDiscordChannelsTool.php
+++ b/app/Mcp/Tools/SocialAccount/ListDiscordChannelsTool.php
@@ -17,7 +17,7 @@
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
-#[Description('List Discord text/announcement channels the bot can post to for a connected Discord server. Use the returned channel id as platforms[].meta.channel_id when creating or updating a Discord post (required to publish).')]
+#[Description('List Discord text/announcement channels the bot can post to for a connected Discord server. Use the returned channel id as meta.channel_id (destinations[].meta.channel_id in create-posts-tool) when creating or updating a Discord post (required to publish).')]
class ListDiscordChannelsTool extends Tool
{
use AuthorizesMcpTool;
diff --git a/app/Mcp/Tools/SocialAccount/ListPinterestBoardsTool.php b/app/Mcp/Tools/SocialAccount/ListPinterestBoardsTool.php
index d51f42d92..a39eb7d0c 100644
--- a/app/Mcp/Tools/SocialAccount/ListPinterestBoardsTool.php
+++ b/app/Mcp/Tools/SocialAccount/ListPinterestBoardsTool.php
@@ -18,7 +18,7 @@
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
-#[Description('List Pinterest boards for a connected Pinterest account. Use the returned board id as platforms[].meta.board_id when creating or updating a Pinterest post (required to publish).')]
+#[Description('List Pinterest boards for a connected Pinterest account. Use the returned board id as meta.board_id (destinations[].meta.board_id in create-posts-tool) when creating or updating a Pinterest post (required to publish).')]
class ListPinterestBoardsTool extends Tool
{
use AuthorizesMcpTool;
diff --git a/app/Mcp/Tools/SocialAccount/MovePostToSlotTool.php b/app/Mcp/Tools/SocialAccount/MovePostToSlotTool.php
index 9bb6c49ad..ee35eb249 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 536143407..12d8266f7 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 521a148b2..d0ac3b401 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 4b0d70739..eed0b88cc 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,33 @@ 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',
+ 'scheduled_before_media_checks',
+ 'last_reconciled_at',
+ 'connection_warning_sent_at',
+ 'publication_updated_at',
+ 'platform_avatar',
];
/**
@@ -68,6 +99,7 @@ class Post extends Model
*/
protected $attributes = [
'origin' => Origin::DEFAULT->value,
+ 'publish_status' => PublishStatus::DEFAULT->value,
];
protected function casts(): array
@@ -88,6 +120,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 +181,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 +215,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 +237,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 +245,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 +310,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 +329,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 +382,195 @@ 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::Failed]);
+ }
+
+ /**
+ * The post has a destination: a channel, or the snapshot of one that was deleted.
+ */
+ public function hasDestination(): bool
{
- $this->update([
- 'status' => PostStatus::PartiallyPublished,
- 'published_at' => now(),
+ return filled($this->platform);
+ }
+
+ /**
+ * The post's channel still exists.
+ */
+ public function hasChannel(): bool
+ {
+ return filled($this->social_account_id);
+ }
+
+ /**
+ * Display name, falling back to the snapshot when the account was deleted.
+ */
+ protected function displayName(): Attribute
+ {
+ return Attribute::make(
+ get: fn (): ?string => $this->socialAccount?->accountDisplayName() ?? $this->platform_name ?? $this->platform?->label(),
+ );
+ }
+
+ /**
+ * Username, falling back to the snapshot when the account was deleted.
+ */
+ protected function displayUsername(): Attribute
+ {
+ return Attribute::make(
+ get: fn (): ?string => $this->socialAccount?->username ?? $this->platform_username,
+ );
+ }
+
+ /**
+ * Avatar URL, falling back to the snapshot when the account was deleted.
+ */
+ protected function displayAvatar(): Attribute
+ {
+ return Attribute::make(
+ get: fn (): ?string => $this->socialAccount?->avatar_url
+ ?: ($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 (blank($this->platform)) {
+ 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 +617,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 8180e64c9..000000000
--- 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 e6cd9b5a5..e74c1edfe 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 a2b49b344..484c48ca4 100644
--- a/app/Observers/PostObserver.php
+++ b/app/Observers/PostObserver.php
@@ -4,10 +4,17 @@
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;
class PostObserver
@@ -26,27 +33,63 @@ public function saving(Post $post): void
public function saved(Post $post): void
{
- if (! $post->wasChanged('status')) {
+ if ($post->wasChanged('status')) {
+ $previousStatus = $post->getOriginal('status');
+
+ DB::afterCommit(fn () => PostStatusChanged::dispatch($post, $previousStatus));
+ }
+ }
+
+ public function updated(Post $post): void
+ {
+ if (! $this->wasJustPublished($post)) {
return;
}
- $previousStatus = $this->previousStatus($post);
+ $this->syncPublicationAnalytics($post);
+ $this->syncPublishingActivity($post);
+ }
- DB::afterCommit(fn () => PostStatusChanged::dispatch($post, $previousStatus));
+ private function wasJustPublished(Post $post): bool
+ {
+ return $post->wasChanged('publish_status')
+ && $post->publish_status === PublishStatus::Published
+ && filled($post->platform_post_id);
}
- private function previousStatus(Post $post): ?PostStatus
+ /**
+ * Never fails the publication: an error here is only reported.
+ */
+ private function syncPublicationAnalytics(Post $post): void
{
- $previous = $post->getRawOriginal('status');
+ rescue(function () use ($post): void {
+ $account = $post->loadMissing('socialAccount')->socialAccount;
- if ($previous instanceof PostStatus) {
- return $previous;
+ if (blank($account) || ! app(FollowerCollectorFactory::class)->supports($post->platform)) {
+ return;
+ }
+
+ SyncTryPostPublication::dispatch(
+ TryPostPublicationIdentity::fromAccount($account, app(ResolveAnalyticsAccountKey::class)->for($account)),
+ $post->id,
+ )->afterCommit();
+ });
+ }
+
+ private function syncPublishingActivity(Post $post): void
+ {
+ if (! PostHogService::isEnabled()) {
+ return;
}
- if (is_string($previous)) {
- return PostStatus::tryFrom($previous);
+ $accountId = $post->loadMissing('workspace')->workspace?->account_id;
+
+ if (blank($accountId)) {
+ return;
}
- return null;
+ SyncAccountPublishingActivity::dispatch((string) $accountId)
+ ->delay(now()->addSeconds(SyncAccountPublishingActivity::DEBOUNCE_SECONDS))
+ ->afterCommit();
}
}
diff --git a/app/Observers/PostPlatformObserver.php b/app/Observers/PostPlatformObserver.php
deleted file mode 100644
index 49fba2694..000000000
--- 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 1cae4e336..67c7db6ec 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 2d9713d01..7ca74c08f 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 fd0da853d..331e16fa6 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
+ * sent next to it: the top-level `social_account_id`, or the sibling of each
+ * `destinations.*.content_type`.
*/
class ContentTypeMatchesPlatform implements DataAwareRule, ValidationRule
{
/** @var array */
private array $data = [];
+ /**
+ * An account outside `$workspaceId` is left to the `exists` rule, so its
+ * network is never revealed.
+ */
+ public function __construct(private ?string $workspaceId) {}
+
/**
* @param array $data
*/
@@ -35,17 +39,14 @@ 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, self::siblingAccountPath($attribute));
if (! $accountId || ! Str::isUuid((string) $accountId)) {
return;
}
$contentType = ContentType::tryFrom((string) $value);
- $account = SocialAccount::find($accountId);
+ $account = SocialAccount::query()->where('workspace_id', $this->workspaceId)->find($accountId);
if (! $contentType || ! $account) {
return;
@@ -59,4 +60,15 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
));
}
}
+
+ /**
+ * The account sent next to the content type: `social_account_id` at the top,
+ * `destinations.{i}.social_account_id` inside a destination.
+ */
+ private static function siblingAccountPath(string $attribute): string
+ {
+ $parent = Str::beforeLast($attribute, '.');
+
+ return $parent === $attribute ? 'social_account_id' : "{$parent}.social_account_id";
+ }
}
diff --git a/app/Rules/ContentTypeMatchesPostChannel.php b/app/Rules/ContentTypeMatchesPostChannel.php
new file mode 100644
index 000000000..82d723f5b
--- /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 e29bbb6e6..000000000
--- 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 e29c46451..15c899800 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 88e091f95..82455a45f 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 (blank($platform)) {
+ 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 3832881c2..8d0f16c39 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,13 @@ 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)
- : null;
+ $content = app(ContentSanitizer::class)->forPost($post);
- $this->account = $postPlatform->socialAccount;
+ $this->account = $post->socialAccount;
$this->hasRetried = false;
if ($this->account->needsProactiveTokenRefresh()) {
@@ -91,28 +89,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 +122,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 +357,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 caa4bef60..e1e045c2a 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 = app(ContentSanitizer::class)->forPost($post);
- $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 = filled(ThreadProgress::rootHash($post->error_context));
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 c74daf965..0f079a672 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 6427f08eb..f8812cb2e 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/ContentSanitizer.php b/app/Services/Social/ContentSanitizer.php
index 6b1c61f5d..45a755e44 100644
--- a/app/Services/Social/ContentSanitizer.php
+++ b/app/Services/Social/ContentSanitizer.php
@@ -5,6 +5,7 @@
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
+use App\Models\Post;
use App\Support\LinkTlds;
class ContentSanitizer
@@ -29,6 +30,14 @@ class ContentSanitizer
private const TYPED_BRACKET_PATTERN = '~<(?!'.self::TAG_BODY.')~i';
+ /**
+ * The post's text as its network receives it, or null when it has none.
+ */
+ public function forPost(Post $post): ?string
+ {
+ return filled($post->content) ? $this->sanitize($post->content, $post->platform) : null;
+ }
+
public function sanitize(string $content, Platform $platform): string
{
$content = $this->escapeTypedBrackets($content);
diff --git a/app/Services/Social/Discord/DiscordPublisher.php b/app/Services/Social/Discord/DiscordPublisher.php
index 44b6a0150..2c2dd12be 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,9 @@ 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 = app(ContentSanitizer::class)->forPost($post) ?? '';
- $content = $this->appendMentions($content, $postPlatform);
+ $content = $this->appendMentions($content, $post);
if (mb_strlen($content) > self::MAX_MESSAGE_LENGTH) {
throw new DiscordPublishException(
@@ -65,13 +63,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 +185,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 +203,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 +235,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 +246,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 cc436ffbb..1a0113bcb 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 b8983f88a..9bc2d3dc1 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,20 @@ 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' => app(ContentSanitizer::class)->forPost($post) ?? '',
'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 +139,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 +150,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 +158,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 +186,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 +209,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 +232,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 7f31c1b78..319ada9e1 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 = app(ContentSanitizer::class)->forPost($post);
- $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 5259af4ec..85497a0f7 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 = app(ContentSanitizer::class)->forPost($post);
- $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 eef5b34a3..e999be95e 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,24 @@ 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)
- : null;
+ $content = app(ContentSanitizer::class)->forPost($post);
- 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 +62,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 +119,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 +131,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 +166,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 +177,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 +189,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 +285,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 +309,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 7692f22cd..97eae9895 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,16 @@ 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 = app(ContentSanitizer::class)->forPost($post) ?? '';
- $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 e2c4eddbf..c650ae398 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 = app(ContentSanitizer::class)->forPost($post);
- $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 8d6b0a5ef..9ead0bdaa 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 ced328f5e..324c221cc 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 = app(ContentSanitizer::class)->forPost($post);
- $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 ade2d3d8f..a2d5d4d60 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 = app(ContentSanitizer::class)->forPost($post);
- $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 6d643dfb3..0f98c6c5d 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,16 @@ public function publish(PostPlatform $postPlatform): array
);
}
- $content = $postPlatform->post->content
- ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform)
- : null;
- $description = $this->resolveDescription($postPlatform, $content);
+ $content = app(ContentSanitizer::class)->forPost($post);
+ $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 +71,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 +239,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 2d8255d9f..13ee2124e 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/ApprovalEmailPost.php b/app/Support/Mail/ApprovalEmailPost.php
new file mode 100644
index 000000000..0ddcd3ae0
--- /dev/null
+++ b/app/Support/Mail/ApprovalEmailPost.php
@@ -0,0 +1,55 @@
+with(['workspace', 'socialAccount'])->find($postId);
+ }
+
+ public static function excerpt(?Post $post): string
+ {
+ return PostExcerpt::from($post?->content, 280);
+ }
+
+ /**
+ * The post itself when it has a channel to show, null for a draft without one.
+ */
+ public static function channel(?Post $post): ?Post
+ {
+ return $post?->hasDestination() ? $post : null;
+ }
+
+ /**
+ * When the approved post goes out; null means it is publishing now.
+ */
+ public static function goesOutAt(?Post $post, User $recipient): ?string
+ {
+ return $post?->status === PostStatus::Scheduled ? self::time($post->scheduled_at, $recipient) : null;
+ }
+
+ public static function time(?CarbonInterface $at, User $recipient): ?string
+ {
+ if (blank($at)) {
+ return null;
+ }
+
+ $dateTime = RecipientTime::dateTime($at, $recipient);
+ $timezone = RecipientTime::timezone($recipient);
+
+ return "{$dateTime} ({$timezone})";
+ }
+}
diff --git a/app/Support/Mail/ApprovalEmailPosts.php b/app/Support/Mail/ApprovalEmailPosts.php
deleted file mode 100644
index cad136fce..000000000
--- a/app/Support/Mail/ApprovalEmailPosts.php
+++ /dev/null
@@ -1,83 +0,0 @@
- $postIds
- * @return Collection
- */
- public static function load(array $postIds): Collection
- {
- return Post::query()
- ->with(['workspace', 'postPlatforms' => fn ($platforms) => $platforms->enabled()->with('socialAccount')])
- ->whereIn('id', $postIds)
- ->orderBy('created_at')
- ->orderBy('id')
- ->get();
- }
-
- public static function excerpt(?Post $post): string
- {
- return PostExcerpt::from($post?->content, 280);
- }
-
- /**
- * @param Collection $posts
- * @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)
- ->values()
- ->all();
- }
-
- /**
- * When each channel goes out once approved: a null time means it is
- * publishing now. One shared moment collapses into a single line.
- *
- * @param Collection $posts
- * @return array{at: ?string, perChannel: list, at: ?string}>}
- */
- public static function goesOut(Collection $posts, User $recipient): array
- {
- $perChannel = $posts->map(fn (Post $post): array => [
- 'channels' => self::channels(collect([$post])),
- 'at' => $post->status === PostStatus::Scheduled ? self::time($post->scheduled_at, $recipient) : null,
- ]);
-
- if ($perChannel->pluck('at')->unique()->count() <= 1) {
- return ['at' => data_get($perChannel->first(), 'at'), 'perChannel' => []];
- }
-
- return ['at' => null, 'perChannel' => $perChannel->values()->all()];
- }
-
- public static function time(?CarbonInterface $at, User $recipient): ?string
- {
- if ($at === null) {
- return null;
- }
-
- $dateTime = RecipientTime::dateTime($at, $recipient);
- $timezone = RecipientTime::timezone($recipient);
-
- return "{$dateTime} ({$timezone})";
- }
-}
diff --git a/app/Support/PostCompositionValidator.php b/app/Support/PostCompositionValidator.php
index 1d4ca4562..d032b249c 100644
--- a/app/Support/PostCompositionValidator.php
+++ b/app/Support/PostCompositionValidator.php
@@ -8,6 +8,7 @@
use App\Actions\Media\SyncOwnedMedia;
use App\Dto\MediaItem;
use App\Enums\Post\QueuePosition;
+use App\Enums\Post\Status;
use App\Enums\PostPlatform\ContentType;
use App\Models\SocialAccount;
use App\Models\Workspace;
@@ -22,6 +23,28 @@
class PostCompositionValidator
{
+ /**
+ * Runs a single-destination write, reporting its errors under the post's own keys
+ * (`meta.title`, `content_type`) instead of `destinations.0.*`.
+ *
+ * @template TResult
+ *
+ * @param callable(): TResult $write
+ * @return TResult
+ */
+ public static function forSinglePost(callable $write): mixed
+ {
+ try {
+ return $write();
+ } catch (ValidationException $exception) {
+ $errors = collect($exception->errors())
+ ->mapWithKeys(fn (array $messages, string $key): array => [Str::after($key, 'destinations.0.') => $messages])
+ ->all();
+
+ throw ValidationException::withMessages($errors);
+ }
+ }
+
/**
* @param array $composition
* @return array
@@ -29,7 +52,7 @@ class PostCompositionValidator
public static function validate(Workspace $workspace, array $composition, array $existingMedia = []): array
{
Validator::make($composition, [
- 'status' => ['required', Rule::in(['draft', 'scheduled', 'publishing'])],
+ 'status' => ['required', Rule::in([Status::Draft->value, Status::Scheduled->value, Status::Publishing->value])],
'content' => ['sometimes', 'nullable', 'string', new PostContentFitsMaxLength],
'media' => ['sometimes', 'array'],
'destinations' => ['required', 'array', 'min:1'],
@@ -63,6 +86,7 @@ public static function validate(Workspace $workspace, array $composition, array
$destination['media_error_key'] = array_key_exists('media', $destination)
? "destinations.{$index}.media"
: 'media';
+ $destination['meta_error_key'] = "destinations.{$index}.meta";
$destination['content'] = array_key_exists('content', $destination)
? $destination['content']
: ($composition['content'] ?? '');
@@ -120,25 +144,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 bc8cc60c7..cff025377 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 (blank($violation)) {
+ 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 (filled($violation)) {
+ [$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 b03b96fb0..dd28c65ed 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 6a72e4d54..57a7121fc 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/Repurpose/RepurposeRules.php b/app/Support/Repurpose/RepurposeRules.php
index 8f2c6160f..361227243 100644
--- a/app/Support/Repurpose/RepurposeRules.php
+++ b/app/Support/Repurpose/RepurposeRules.php
@@ -66,7 +66,7 @@ public static function destinations(?string $workspaceId): array
'required',
'string',
Rule::enum(ContentType::class),
- new ContentTypeMatchesPlatform,
+ new ContentTypeMatchesPlatform($workspaceId),
fn (string $attribute, mixed $value, callable $fail) => ContentType::tryFrom((string) $value)?->supportsVideo() === false
? $fail(__('repurposes.errors.destination_needs_video'))
: null,
diff --git a/app/Support/Requests/Post/PostRequestRules.php b/app/Support/Requests/Post/PostRequestRules.php
index 8ecb976bc..eba2959eb 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', []);
+ $accounts = 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'),
+ $accounts,
+ $accounts->map(fn (): array => (array) data_get($input, 'meta', []))->all(),
+ $accounts->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($workspace->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(filled($target) ? ['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,37 +168,25 @@ 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. A
+ * malformed id never reaches the query, which PostgreSQL would reject.
*
* @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 === []) {
- return collect();
- }
-
- return SocialAccount::query()
- ->where('workspace_id', $workspace->id)
- ->whereIn('id', $accountIds)
- ->get()
- ->keyBy('id');
+ return Str::isUuid($accountId)
+ ? $workspace->socialAccounts()->whereKey($accountId)->get()->keyBy('id')
+ : collect();
}
/**
@@ -273,7 +228,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 +236,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 +246,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
deleted file mode 100644
index 948f91c33..000000000
--- a/app/Support/Social/AbandonGoogleBusinessReview.php
+++ /dev/null
@@ -1,37 +0,0 @@
- $errorContext
- */
- public static function execute(PostPlatform $postPlatform, string $errorMessage, array $errorContext = []): void
- {
- if ($postPlatform->status === Status::PendingReview) {
- $postPlatform->markAsRejected(
- (string) $postPlatform->platform_post_id,
- $postPlatform->platform_url,
- $errorMessage,
- $errorContext,
- );
- }
-
- app(GoogleBusinessDerivativeCleaner::class)->cleanup($postPlatform->id);
- app(FinalizePostPublication::class)->handle($postPlatform->post);
- PostPlatformStatusUpdated::dispatch($postPlatform->fresh());
- }
-}
diff --git a/app/Support/Social/GoogleBusinessDerivativeCleaner.php b/app/Support/Social/GoogleBusinessDerivativeCleaner.php
index 0148d8599..6ba9c6210 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,23 @@ class GoogleBusinessDerivativeCleaner
{
public const string DIRECTORY = 'google-business-derivatives';
- public static function pathFor(string $postPlatformId): string
+ /**
+ * The JPEG is named after the post.
+ */
+ public static function pathFor(Post $post): string
{
- return self::DIRECTORY."/{$postPlatformId}.jpg";
+ return self::DIRECTORY."/{$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 fa11a4954..773bf7cb4 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 302878a49..012dbba6f 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 1ff2e4af3..37ed7663b 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/config/trypost.php b/config/trypost.php
index 8c8d0f80b..a682d06aa 100644
--- a/config/trypost.php
+++ b/config/trypost.php
@@ -176,7 +176,7 @@
| Post history retention
|--------------------------------------------------------------------------
|
- | Published and partially published posts older than this many days are
+ | Published posts older than this many days are
| deleted with their media by posts:prune-history. Must be 1 or more.
|
*/
diff --git a/database/factories/AnalyticsPublicationFactory.php b/database/factories/AnalyticsPublicationFactory.php
index afd31c296..bb9536075 100644
--- a/database/factories/AnalyticsPublicationFactory.php
+++ b/database/factories/AnalyticsPublicationFactory.php
@@ -28,7 +28,7 @@ public function definition(): array
'workspace_id' => Workspace::factory(),
'social_account_id' => null,
'social_account_key' => fake()->uuid(),
- 'post_platform_id' => null,
+ 'post_id' => null,
'network' => Platform::Instagram->network(),
'platform_user_id' => fake()->uuid(),
'platform' => Platform::Instagram,
diff --git a/database/factories/PostFactory.php b/database/factories/PostFactory.php
index 911e146f6..61e6ed4e1 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 72da643ee..000000000
--- 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 000000000..f4d1368ad
--- /dev/null
+++ b/database/migrations/2026_10_08_222940_prepare_post_platforms_for_merge_into_posts.php
@@ -0,0 +1,162 @@
+whereNull('social_account_id')->delete();
+
+ DB::table('post_platforms')
+ ->where('enabled', false)
+ ->where('status', '!=', 'published')
+ ->delete();
+
+ $this->settlePartiallyPublishedPosts();
+ $this->turnPostsWithoutDestinationIntoDrafts();
+ $this->deletePublishedPostsWithoutDestination();
+ $this->assertEveryPostHasOneDestination();
+ });
+ }
+
+ private function settlePartiallyPublishedPosts(): void
+ {
+ DB::table('posts')
+ ->where('status', 'partially_published')
+ ->whereIn('id', $this->postsWithTargets(exactly: 1))
+ ->select('id')
+ ->chunkById(500, function (Collection $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 deletePublishedPostsWithoutDestination(): void
+ {
+ $postIds = DB::table('posts')
+ ->whereIn('status', ['published', 'partially_published'])
+ ->whereNotIn('id', DB::table('post_platforms')->select('post_id'))
+ ->orderBy('id')
+ ->pluck('id')
+ ->all();
+
+ $paths = [];
+
+ foreach (array_chunk($postIds, 500) as $chunk) {
+ array_push($paths, ...DB::table('medias')->whereIn('post_id', $chunk)->pluck('path')->all());
+ DB::table('medias')->whereIn('post_id', $chunk)->delete();
+ DB::table('posts')->whereIn('id', $chunk)->delete();
+ }
+
+ DB::afterCommit(fn () => $this->deleteUnreferencedFiles(array_values(array_unique(array_filter($paths)))));
+ }
+
+ /**
+ * Files are removed only after the deletion commits, and only when no
+ * other media row still points at them.
+ *
+ * @param list $paths
+ */
+ private function deleteUnreferencedFiles(array $paths): void
+ {
+ foreach (array_chunk($paths, 500) as $chunk) {
+ $referenced = DB::table('medias')->whereIn('path', $chunk)->distinct()->pluck('path')->all();
+
+ foreach (array_diff($chunk, $referenced) as $path) {
+ rescue(fn (): bool => Storage::delete($path), report: false);
+ }
+ }
+ }
+
+ 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(),
+ '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 000000000..e6ba9af1e
--- /dev/null
+++ b/database/migrations/2026_10_08_222942_add_target_columns_to_posts_table.php
@@ -0,0 +1,96 @@
+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->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->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',
+ ]);
+ });
+ }
+};
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 000000000..1c72f6b22
--- /dev/null
+++ b/database/migrations/2026_10_08_222943_backfill_posts_from_post_platforms.php
@@ -0,0 +1,215 @@
+where(fn (Builder $query) => $query->where('enabled', false)->orWhereNull('social_account_id'))->count();
+
+ if ($unresolved > 0) {
+ throw new RuntimeException("{$unresolved} destinations are switched off or lost their channel; roll back and run the migrations again from 2026_10_08_222940.");
+ }
+
+ $this->clearCopiesOfVanishedTargets();
+ $this->copyTargets();
+ $this->linkAnalyticsPublications();
+ $this->assertParity();
+ $this->replacePartiallyPublishedWebhookEvent();
+ }
+
+ private function clearCopiesOfVanishedTargets(): void
+ {
+ DB::table('posts')
+ ->whereNotNull('platform')
+ ->whereNotIn('id', DB::table('post_platforms')->select('post_id'))
+ ->update([
+ ...array_fill_keys(self::TARGET_COLUMNS, null),
+ 'scheduled_before_media_checks' => false,
+ 'publish_status' => 'pending',
+ 'publication_updated_at' => 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 (Collection $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_status === 'published' ? ($target->p_published_at ?? $target->pp_published_at) : $target->pp_published_at,
+ 'publication_updated_at' => $target->pp_updated_at,
+ ]);
+ }
+ });
+ }, '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 (Collection $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();
+ $posts = DB::table('post_platforms')->distinct()->count('post_id');
+ $copies = DB::table('posts')->whereNotNull('platform')->count();
+
+ if ($targets !== $posts || $targets !== $copies) {
+ throw new RuntimeException("Backfill parity failed: {$targets} destinations on {$posts} posts, {$copies} posts carry one.");
+ }
+
+ $scalarColumns = array_values(array_diff(self::TARGET_COLUMNS, self::JSON_COLUMNS));
+
+ DB::table('post_platforms')
+ ->join('posts', 'posts.id', '=', 'post_platforms.post_id')
+ ->select([
+ 'post_platforms.id as pp_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 (Collection $rows) use ($scalarColumns): void {
+ foreach ($rows as $row) {
+ $mismatch = $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 (Collection $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 000000000..887d8acff
--- /dev/null
+++ b/database/migrations/2026_10_08_222944_drop_post_platforms_table.php
@@ -0,0 +1,41 @@
+whereNotExists(fn (Builder $query) => $query->select(DB::raw(1))
+ ->from('posts')
+ ->whereColumn('posts.id', 'post_platforms.post_id')
+ ->whereColumn('posts.platform', 'post_platforms.platform')
+ ->whereColumn('posts.social_account_id', 'post_platforms.social_account_id'))
+ ->count();
+
+ if ($unmirrored > 0) {
+ throw new RuntimeException("{$unmirrored} destinations were not copied to their posts; post_platforms was kept.");
+ }
+
+ $unlinked = DB::table('analytics_publications')->whereNotNull('post_platform_id')->whereNull('post_id')->count();
+
+ if ($unlinked > 0) {
+ throw new RuntimeException("{$unlinked} analytics publications were not linked 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/lang/ar/mail.php b/lang/ar/mail.php
index b50bf71fc..dc6de63ea 100644
--- a/lang/ar/mail.php
+++ b/lang/ar/mail.php
@@ -68,7 +68,7 @@
'preview' => ':name طلب الموافقة في :workspace.',
'heading' => 'منشور يحتاج إلى موافقتك',
'body' => ':name (:email) طلب الموافقة في مساحة العمل :workspace.',
- 'channels' => 'القنوات',
+ 'channel' => 'القناة',
'requested_time' => 'الوقت المطلوب',
'next_queue_slot' => 'الموعد التالي في قائمة الانتظار',
'as_soon_as_approved' => 'فور الموافقة عليه',
@@ -82,7 +82,7 @@
'preview' => ':name وافق على منشورك في :workspace.',
'heading' => 'تمت الموافقة على منشورك',
'body' => ':name وافق على منشورك في مساحة العمل :workspace.',
- 'channels' => 'القنوات',
+ 'channel' => 'القناة',
'goes_out' => 'موعد النشر',
'channel_time' => ':channel: :time',
'publishing_now' => 'يُنشر الآن',
@@ -95,7 +95,7 @@
'preview' => ':name أعاد منشورك إلى المسودات.',
'heading' => 'لم تتم الموافقة على منشورك',
'body' => ':name أعاد منشورك في مساحة العمل :workspace إلى المسودات.',
- 'channels' => 'القنوات',
+ 'channel' => 'القناة',
'button' => 'عرض في المسودات',
],
diff --git a/lang/ar/posts.php b/lang/ar/posts.php
index aa40ee33d..359071350 100644
--- a/lang/ar/posts.php
+++ b/lang/ar/posts.php
@@ -533,7 +533,6 @@
'publishing' => 'قيد النشر',
'retrying' => 'إعادة المحاولة',
'published' => 'منشور',
- 'partially_published' => 'منشور جزئيًا',
'failed' => 'فشل',
],
@@ -639,17 +638,6 @@
'flags' => 'الأعلام',
],
- 'status' => [
- 'pending' => 'قيد الانتظار',
- 'scheduled' => 'مجدول',
- 'published' => 'منشور',
- 'publishing' => 'جارٍ النشر...',
- 'retrying' => 'جارٍ إعادة المحاولة...',
- 'failed' => 'فشل',
- 'pending_review' => 'قيد مراجعة Google',
- 'rejected' => 'مرفوض',
- ],
-
'delete_modal' => [
'title' => 'حذف المنشور',
'description' => 'هل أنت متأكد من رغبتك في حذف هذا المنشور؟ لا يمكن التراجع عن هذا الإجراء.',
@@ -819,12 +807,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' => 'ظلت المنصة غير متاحة بعد عدة محاولات. يرجى المحاولة لاحقًا.',
@@ -906,6 +893,7 @@
'publishing_now' => 'يُنشر الآن',
'publishing_badge' => 'جارٍ النشر…',
'publishing_on' => 'جارٍ النشر على :network…',
+ 'in_google_review' => 'قيد مراجعة Google',
'retrying_at' => 'إعادة المحاولة في :time',
'published_via' => 'نُشر عبر',
'published_directly_from' => 'نُشر مباشرةً من :network',
@@ -992,8 +980,6 @@
],
'group' => [
'channels' => 'القنوات (:count)',
- 'collapse' => 'إخفاء القنوات',
- 'expand' => 'إظهار القنوات',
],
'approvals' => [
'badge' => 'موافقة',
diff --git a/lang/ar/webhooks.php b/lang/ar/webhooks.php
index caa17aba7..8695382bd 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/mail.php b/lang/de/mail.php
index 58199ef60..ce0533421 100644
--- a/lang/de/mail.php
+++ b/lang/de/mail.php
@@ -68,7 +68,7 @@
'preview' => ':name bittet in :workspace um Freigabe.',
'heading' => 'Ein Beitrag braucht deine Freigabe',
'body' => ':name (:email) bittet im Workspace :workspace um Freigabe.',
- 'channels' => 'Kanäle',
+ 'channel' => 'Kanal',
'requested_time' => 'Gewünschter Zeitpunkt',
'next_queue_slot' => 'Nächster Platz in der Warteschlange',
'as_soon_as_approved' => 'Sobald er freigegeben ist',
@@ -82,7 +82,7 @@
'preview' => ':name hat deinen Beitrag in :workspace freigegeben.',
'heading' => 'Dein Beitrag wurde freigegeben',
'body' => ':name hat deinen Beitrag im Workspace :workspace freigegeben.',
- 'channels' => 'Kanäle',
+ 'channel' => 'Kanal',
'goes_out' => 'Wird veröffentlicht',
'channel_time' => ':channel: :time',
'publishing_now' => 'Wird jetzt veröffentlicht',
@@ -95,7 +95,7 @@
'preview' => ':name hat deinen Beitrag zurück in die Entwürfe verschoben.',
'heading' => 'Dein Beitrag wurde nicht freigegeben',
'body' => ':name hat deinen Beitrag im Workspace :workspace zurück in die Entwürfe verschoben.',
- 'channels' => 'Kanäle',
+ 'channel' => 'Kanal',
'button' => 'In den Entwürfen ansehen',
],
diff --git a/lang/de/posts.php b/lang/de/posts.php
index 83d920fd8..34fdd6f91 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,17 +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',
'description' => 'Möchtest du diesen Beitrag wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.',
@@ -821,12 +809,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.',
@@ -908,6 +895,7 @@
'publishing_now' => 'Wird jetzt veröffentlicht',
'publishing_badge' => 'Veröffentlichen…',
'publishing_on' => 'Wird auf :network veröffentlicht…',
+ 'in_google_review' => 'Google-Prüfung läuft',
'retrying_at' => 'Neuer Versuch um :time',
'published_via' => 'Veröffentlicht über',
'published_directly_from' => 'Direkt über :network veröffentlicht',
@@ -994,8 +982,6 @@
],
'group' => [
'channels' => 'Kanäle (:count)',
- 'collapse' => 'Kanäle ausblenden',
- 'expand' => 'Kanäle anzeigen',
],
'approvals' => [
'badge' => 'Freigabe',
diff --git a/lang/de/webhooks.php b/lang/de/webhooks.php
index 3afbc1e28..e28f490f7 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/mail.php b/lang/el/mail.php
index b43326ccd..edc9fc7d3 100644
--- a/lang/el/mail.php
+++ b/lang/el/mail.php
@@ -68,7 +68,7 @@
'preview' => 'Ο/Η :name ζήτησε έγκριση στο :workspace.',
'heading' => 'Μια ανάρτηση χρειάζεται την έγκρισή σας',
'body' => 'Ο/Η :name (:email) ζήτησε έγκριση στο workspace :workspace.',
- 'channels' => 'Κανάλια',
+ 'channel' => 'Κανάλι',
'requested_time' => 'Ζητούμενη ώρα',
'next_queue_slot' => 'Επόμενη θέση στην ουρά',
'as_soon_as_approved' => 'Μόλις εγκριθεί',
@@ -82,7 +82,7 @@
'preview' => 'Ο/Η :name ενέκρινε την ανάρτησή σας στο :workspace.',
'heading' => 'Η ανάρτησή σας εγκρίθηκε',
'body' => 'Ο/Η :name ενέκρινε την ανάρτησή σας στο workspace :workspace.',
- 'channels' => 'Κανάλια',
+ 'channel' => 'Κανάλι',
'goes_out' => 'Θα δημοσιευτεί',
'channel_time' => ':channel: :time',
'publishing_now' => 'Δημοσιεύεται τώρα',
@@ -95,7 +95,7 @@
'preview' => 'Ο/Η :name μετέφερε την ανάρτησή σας πίσω στα πρόχειρα.',
'heading' => 'Η ανάρτησή σας δεν εγκρίθηκε',
'body' => 'Ο/Η :name μετέφερε την ανάρτησή σας στο workspace :workspace πίσω στα πρόχειρα.',
- 'channels' => 'Κανάλια',
+ 'channel' => 'Κανάλι',
'button' => 'Προβολή στα πρόχειρα',
],
diff --git a/lang/el/posts.php b/lang/el/posts.php
index 2903149aa..40a5fd543 100644
--- a/lang/el/posts.php
+++ b/lang/el/posts.php
@@ -533,7 +533,6 @@
'publishing' => 'Δημοσιεύεται',
'retrying' => 'Επανάληψη',
'published' => 'Δημοσιεύτηκε',
- 'partially_published' => 'Δημοσιεύτηκε εν μέρει',
'failed' => 'Απέτυχε',
],
@@ -639,17 +638,6 @@
'flags' => 'Σημαίες',
],
- 'status' => [
- 'pending' => 'Σε εκκρεμότητα',
- 'scheduled' => 'Προγραμματισμένη',
- 'published' => 'Δημοσιεύτηκε',
- 'publishing' => 'Δημοσίευση...',
- 'retrying' => 'Επανάληψη...',
- 'failed' => 'Απέτυχε',
- 'pending_review' => 'Σε έλεγχο από την Google',
- 'rejected' => 'Απορρίφθηκε',
- ],
-
'delete_modal' => [
'title' => 'Διαγραφή δημοσίευσης',
'description' => 'Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτή τη δημοσίευση; Αυτή η ενέργεια δεν μπορεί να αναιρεθεί.',
@@ -819,12 +807,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' => 'Η πλατφόρμα παρέμεινε μη διαθέσιμη μετά από αρκετές προσπάθειες. Δοκιμάστε ξανά αργότερα.',
@@ -906,6 +893,7 @@
'publishing_now' => 'Δημοσιεύεται τώρα',
'publishing_badge' => 'Δημοσίευση…',
'publishing_on' => 'Δημοσίευση στο :network…',
+ 'in_google_review' => 'Σε έλεγχο από την Google',
'retrying_at' => 'Νέα προσπάθεια στις :time',
'published_via' => 'Δημοσιεύτηκε μέσω',
'published_directly_from' => 'Δημοσιεύτηκε απευθείας από το :network',
@@ -992,8 +980,6 @@
],
'group' => [
'channels' => 'Κανάλια (:count)',
- 'collapse' => 'Απόκρυψη καναλιών',
- 'expand' => 'Εμφάνιση καναλιών',
],
'approvals' => [
'badge' => 'Προς έγκριση',
diff --git a/lang/el/webhooks.php b/lang/el/webhooks.php
index 8161a611d..7587498aa 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/mail.php b/lang/en/mail.php
index f621d2f2c..d865ae819 100644
--- a/lang/en/mail.php
+++ b/lang/en/mail.php
@@ -68,7 +68,7 @@
'preview' => ':name asked for approval in :workspace.',
'heading' => 'A post needs your approval',
'body' => ':name (:email) asked for approval in the :workspace workspace.',
- 'channels' => 'Channels',
+ 'channel' => 'Channel',
'requested_time' => 'Requested time',
'next_queue_slot' => 'Next queue slot',
'as_soon_as_approved' => 'As soon as it\'s approved',
@@ -82,7 +82,7 @@
'preview' => ':name approved your post in :workspace.',
'heading' => 'Your post was approved',
'body' => ':name approved your post in the :workspace workspace.',
- 'channels' => 'Channels',
+ 'channel' => 'Channel',
'goes_out' => 'It will go out',
'channel_time' => ':channel: :time',
'publishing_now' => 'Publishing now',
@@ -95,7 +95,7 @@
'preview' => ':name moved your post back to drafts.',
'heading' => 'Your post was not approved',
'body' => ':name moved your post in the :workspace workspace back to drafts.',
- 'channels' => 'Channels',
+ 'channel' => 'Channel',
'button' => 'View in drafts',
],
diff --git a/lang/en/posts.php b/lang/en/posts.php
index 198fa3fa6..d469dee19 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,17 +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',
'description' => 'Are you sure you want to delete this post? This action cannot be undone.',
@@ -819,12 +807,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.',
@@ -906,6 +893,7 @@
'publishing_now' => 'Publishing now',
'publishing_badge' => 'Publishing…',
'publishing_on' => 'Publishing on :network…',
+ 'in_google_review' => 'In review by Google',
'retrying_at' => 'Retrying at :time',
'published_via' => 'Published via',
'published_directly_from' => 'This was published directly from :network',
@@ -992,8 +980,6 @@
],
'group' => [
'channels' => 'Channels (:count)',
- 'collapse' => 'Hide channels',
- 'expand' => 'Show channels',
],
'approvals' => [
'badge' => 'Approval',
diff --git a/lang/en/webhooks.php b/lang/en/webhooks.php
index 30a2cf155..1cd83d8d8 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/mail.php b/lang/es/mail.php
index e73cfb045..062c79c1a 100644
--- a/lang/es/mail.php
+++ b/lang/es/mail.php
@@ -68,7 +68,7 @@
'preview' => ':name pidió aprobación en :workspace.',
'heading' => 'Una publicación necesita tu aprobación',
'body' => ':name (:email) pidió aprobación en el workspace :workspace.',
- 'channels' => 'Canales',
+ 'channel' => 'Canal',
'requested_time' => 'Hora solicitada',
'next_queue_slot' => 'Próximo hueco de la cola',
'as_soon_as_approved' => 'En cuanto se apruebe',
@@ -82,7 +82,7 @@
'preview' => ':name aprobó tu publicación en :workspace.',
'heading' => 'Tu publicación fue aprobada',
'body' => ':name aprobó tu publicación en el workspace :workspace.',
- 'channels' => 'Canales',
+ 'channel' => 'Canal',
'goes_out' => 'Se publicará',
'channel_time' => ':channel: :time',
'publishing_now' => 'Publicando ahora',
@@ -95,7 +95,7 @@
'preview' => ':name devolvió tu publicación a borradores.',
'heading' => 'Tu publicación no fue aprobada',
'body' => ':name devolvió tu publicación del workspace :workspace a borradores.',
- 'channels' => 'Canales',
+ 'channel' => 'Canal',
'button' => 'Ver en borradores',
],
diff --git a/lang/es/posts.php b/lang/es/posts.php
index 5f5339afb..a699ac95f 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,17 +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',
'description' => '¿Estás seguro de que deseas eliminar este post? Esta acción no se puede deshacer.',
@@ -819,12 +807,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.',
@@ -907,6 +894,7 @@
'publishing_now' => 'Publicando ahora',
'publishing_badge' => 'Publicando…',
'publishing_on' => 'Publicando en :network…',
+ 'in_google_review' => 'En revisión por Google',
'retrying_at' => 'Reintento a las :time',
'published_via' => 'Publicado en',
'published_directly_from' => 'Publicado directamente desde :network',
@@ -993,8 +981,6 @@
],
'group' => [
'channels' => 'Canales (:count)',
- 'collapse' => 'Ocultar canales',
- 'expand' => 'Mostrar canales',
],
'approvals' => [
'badge' => 'Aprobación',
diff --git a/lang/es/webhooks.php b/lang/es/webhooks.php
index 1c6763141..0a49be334 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/mail.php b/lang/fr/mail.php
index d2dffb09e..8292e8c6b 100644
--- a/lang/fr/mail.php
+++ b/lang/fr/mail.php
@@ -68,7 +68,7 @@
'preview' => ':name demande une approbation dans :workspace.',
'heading' => 'Une publication attend votre approbation',
'body' => ':name (:email) demande une approbation dans l\'espace de travail :workspace.',
- 'channels' => 'Canaux',
+ 'channel' => 'Canal',
'requested_time' => 'Heure demandée',
'next_queue_slot' => 'Prochain créneau de la file d\'attente',
'as_soon_as_approved' => 'Dès son approbation',
@@ -82,7 +82,7 @@
'preview' => ':name a approuvé votre publication dans :workspace.',
'heading' => 'Votre publication a été approuvée',
'body' => ':name a approuvé votre publication dans l\'espace de travail :workspace.',
- 'channels' => 'Canaux',
+ 'channel' => 'Canal',
'goes_out' => 'Publication prévue',
'channel_time' => ':channel : :time',
'publishing_now' => 'Publication en cours',
@@ -95,7 +95,7 @@
'preview' => ':name a remis votre publication dans les brouillons.',
'heading' => 'Votre publication n\'a pas été approuvée',
'body' => ':name a remis votre publication de l\'espace de travail :workspace dans les brouillons.',
- 'channels' => 'Canaux',
+ 'channel' => 'Canal',
'button' => 'Voir dans les brouillons',
],
diff --git a/lang/fr/posts.php b/lang/fr/posts.php
index f79e4c018..721c6040c 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,17 +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',
'description' => 'Voulez-vous vraiment supprimer cette publication ? Cette action est irréversible.',
@@ -819,12 +807,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.',
@@ -906,6 +893,7 @@
'publishing_now' => 'Publication en cours',
'publishing_badge' => 'Publication…',
'publishing_on' => 'Publication sur :network…',
+ 'in_google_review' => 'En cours d\'examen par Google',
'retrying_at' => 'Nouvel essai à :time',
'published_via' => 'Publié via',
'published_directly_from' => 'Publié directement depuis :network',
@@ -992,8 +980,6 @@
],
'group' => [
'channels' => 'Canaux (:count)',
- 'collapse' => 'Masquer les canaux',
- 'expand' => 'Afficher les canaux',
],
'approvals' => [
'badge' => 'Approbation',
diff --git a/lang/fr/webhooks.php b/lang/fr/webhooks.php
index d1b2b54a3..7d5c9aad8 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/mail.php b/lang/it/mail.php
index 83cd8bbdc..bd6bb95a4 100644
--- a/lang/it/mail.php
+++ b/lang/it/mail.php
@@ -68,7 +68,7 @@
'preview' => ':name ha chiesto un\'approvazione in :workspace.',
'heading' => 'Un post richiede la tua approvazione',
'body' => ':name (:email) ha chiesto un\'approvazione nel workspace :workspace.',
- 'channels' => 'Canali',
+ 'channel' => 'Canale',
'requested_time' => 'Orario richiesto',
'next_queue_slot' => 'Prossimo slot della coda',
'as_soon_as_approved' => 'Appena viene approvato',
@@ -82,7 +82,7 @@
'preview' => ':name ha approvato il tuo post in :workspace.',
'heading' => 'Il tuo post è stato approvato',
'body' => ':name ha approvato il tuo post nel workspace :workspace.',
- 'channels' => 'Canali',
+ 'channel' => 'Canale',
'goes_out' => 'Verrà pubblicato',
'channel_time' => ':channel: :time',
'publishing_now' => 'Pubblicazione in corso',
@@ -95,7 +95,7 @@
'preview' => ':name ha riportato il tuo post nelle bozze.',
'heading' => 'Il tuo post non è stato approvato',
'body' => ':name ha riportato il tuo post del workspace :workspace nelle bozze.',
- 'channels' => 'Canali',
+ 'channel' => 'Canale',
'button' => 'Vedi nelle bozze',
],
diff --git a/lang/it/posts.php b/lang/it/posts.php
index d31767631..4a48423f9 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,17 +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',
'description' => 'Vuoi davvero eliminare questo post? Questa azione non può essere annullata.',
@@ -819,12 +807,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.',
@@ -906,6 +893,7 @@
'publishing_now' => 'In pubblicazione',
'publishing_badge' => 'Pubblicazione…',
'publishing_on' => 'Pubblicazione su :network…',
+ 'in_google_review' => 'In revisione da Google',
'retrying_at' => 'Nuovo tentativo alle :time',
'published_via' => 'Pubblicato tramite',
'published_directly_from' => 'Pubblicato direttamente da :network',
@@ -992,8 +980,6 @@
],
'group' => [
'channels' => 'Canali (:count)',
- 'collapse' => 'Nascondi canali',
- 'expand' => 'Mostra canali',
],
'approvals' => [
'badge' => 'Approvazione',
diff --git a/lang/it/webhooks.php b/lang/it/webhooks.php
index 2a0a08625..456a3ec30 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/mail.php b/lang/ja/mail.php
index 3fbd1b6de..75db61425 100644
--- a/lang/ja/mail.php
+++ b/lang/ja/mail.php
@@ -68,7 +68,7 @@
'preview' => ':nameさんが:workspaceで承認を依頼しました。',
'heading' => '承認が必要な投稿があります',
'body' => ':nameさん(:email)がワークスペース「:workspace」で承認を依頼しました。',
- 'channels' => 'チャンネル',
+ 'channel' => 'チャンネル',
'requested_time' => '希望時刻',
'next_queue_slot' => 'キューの次の枠',
'as_soon_as_approved' => '承認されしだい',
@@ -82,7 +82,7 @@
'preview' => ':nameさんが:workspaceであなたの投稿を承認しました。',
'heading' => '投稿が承認されました',
'body' => ':nameさんがワークスペース「:workspace」であなたの投稿を承認しました。',
- 'channels' => 'チャンネル',
+ 'channel' => 'チャンネル',
'goes_out' => '公開予定',
'channel_time' => ':channel::time',
'publishing_now' => '今すぐ公開中',
@@ -95,7 +95,7 @@
'preview' => ':nameさんがあなたの投稿を下書きに戻しました。',
'heading' => '投稿は承認されませんでした',
'body' => ':nameさんがワークスペース「:workspace」のあなたの投稿を下書きに戻しました。',
- 'channels' => 'チャンネル',
+ 'channel' => 'チャンネル',
'button' => '下書きで見る',
],
diff --git a/lang/ja/posts.php b/lang/ja/posts.php
index 6f9540492..5b76c7947 100644
--- a/lang/ja/posts.php
+++ b/lang/ja/posts.php
@@ -533,7 +533,6 @@
'publishing' => '公開中',
'retrying' => '再試行中',
'published' => '公開済み',
- 'partially_published' => '一部公開済み',
'failed' => '失敗',
],
@@ -639,17 +638,6 @@
'flags' => '旗',
],
- 'status' => [
- 'pending' => '保留中',
- 'scheduled' => '予約済み',
- 'published' => '公開済み',
- 'publishing' => '公開中...',
- 'retrying' => '再試行中...',
- 'failed' => '失敗',
- 'pending_review' => 'Google が審査中',
- 'rejected' => '拒否されました',
- ],
-
'delete_modal' => [
'title' => '投稿を削除',
'description' => 'この投稿を削除してもよろしいですか?この操作は取り消せません。',
@@ -819,12 +807,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' => '何度か再試行しましたがプラットフォームが利用できませんでした。後でもう一度お試しください。',
@@ -906,6 +893,7 @@
'publishing_now' => '公開中',
'publishing_badge' => '公開中…',
'publishing_on' => ':network に投稿中…',
+ 'in_google_review' => 'Google が審査中',
'retrying_at' => ':time に再試行',
'published_via' => '公開先:',
'published_directly_from' => ':network から直接公開されました',
@@ -992,8 +980,6 @@
],
'group' => [
'channels' => 'チャンネル (:count)',
- 'collapse' => 'チャンネルを隠す',
- 'expand' => 'チャンネルを表示',
],
'approvals' => [
'badge' => '承認',
diff --git a/lang/ja/webhooks.php b/lang/ja/webhooks.php
index 4e36e94c9..7ff7d14f2 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/mail.php b/lang/ko/mail.php
index 031796d74..f81f08f32 100644
--- a/lang/ko/mail.php
+++ b/lang/ko/mail.php
@@ -68,7 +68,7 @@
'preview' => ':name님이 :workspace에서 승인을 요청했습니다.',
'heading' => '승인이 필요한 게시물이 있습니다',
'body' => ':name님(:email)이 :workspace 워크스페이스에서 승인을 요청했습니다.',
- 'channels' => '채널',
+ 'channel' => '채널',
'requested_time' => '요청한 시간',
'next_queue_slot' => '대기열의 다음 시간',
'as_soon_as_approved' => '승인되는 즉시',
@@ -82,7 +82,7 @@
'preview' => ':name님이 :workspace에서 게시물을 승인했습니다.',
'heading' => '게시물이 승인되었습니다',
'body' => ':name님이 :workspace 워크스페이스에서 게시물을 승인했습니다.',
- 'channels' => '채널',
+ 'channel' => '채널',
'goes_out' => '게시 예정',
'channel_time' => ':channel: :time',
'publishing_now' => '지금 게시 중',
@@ -95,7 +95,7 @@
'preview' => ':name님이 게시물을 초안으로 되돌렸습니다.',
'heading' => '게시물이 승인되지 않았습니다',
'body' => ':name님이 :workspace 워크스페이스의 게시물을 초안으로 되돌렸습니다.',
- 'channels' => '채널',
+ 'channel' => '채널',
'button' => '초안에서 보기',
],
diff --git a/lang/ko/posts.php b/lang/ko/posts.php
index 0df056530..bc3b61238 100644
--- a/lang/ko/posts.php
+++ b/lang/ko/posts.php
@@ -533,7 +533,6 @@
'publishing' => '게시 중',
'retrying' => '재시도 중',
'published' => '게시됨',
- 'partially_published' => '부분 게시됨',
'failed' => '실패',
],
@@ -639,17 +638,6 @@
'flags' => '깃발',
],
- 'status' => [
- 'pending' => '대기 중',
- 'scheduled' => '예약됨',
- 'published' => '게시됨',
- 'publishing' => '게시 중...',
- 'retrying' => '재시도 중...',
- 'failed' => '실패',
- 'pending_review' => 'Google 검토 중',
- 'rejected' => '거부됨',
- ],
-
'delete_modal' => [
'title' => '게시물 삭제',
'description' => '이 게시물을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.',
@@ -819,12 +807,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' => '여러 번 재시도했지만 플랫폼을 사용할 수 없었습니다. 나중에 다시 시도하세요.',
@@ -906,6 +893,7 @@
'publishing_now' => '지금 게시 중',
'publishing_badge' => '게시 중…',
'publishing_on' => ':network에 게시 중…',
+ 'in_google_review' => 'Google 검토 중',
'retrying_at' => ':time에 재시도',
'published_via' => '게시 채널:',
'published_directly_from' => ':network에서 직접 게시됨',
@@ -992,8 +980,6 @@
],
'group' => [
'channels' => '채널 (:count)',
- 'collapse' => '채널 숨기기',
- 'expand' => '채널 보기',
],
'approvals' => [
'badge' => '승인',
diff --git a/lang/ko/webhooks.php b/lang/ko/webhooks.php
index f9a042906..67cd2cd23 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/mail.php b/lang/nl/mail.php
index 86438f826..9504da053 100644
--- a/lang/nl/mail.php
+++ b/lang/nl/mail.php
@@ -68,7 +68,7 @@
'preview' => ':name vraagt om goedkeuring in :workspace.',
'heading' => 'Een bericht wacht op je goedkeuring',
'body' => ':name (:email) vraagt om goedkeuring in de workspace :workspace.',
- 'channels' => 'Kanalen',
+ 'channel' => 'Kanaal',
'requested_time' => 'Gevraagd tijdstip',
'next_queue_slot' => 'Volgende plek in de wachtrij',
'as_soon_as_approved' => 'Zodra het is goedgekeurd',
@@ -82,7 +82,7 @@
'preview' => ':name heeft je bericht in :workspace goedgekeurd.',
'heading' => 'Je bericht is goedgekeurd',
'body' => ':name heeft je bericht in de workspace :workspace goedgekeurd.',
- 'channels' => 'Kanalen',
+ 'channel' => 'Kanaal',
'goes_out' => 'Wordt gepubliceerd',
'channel_time' => ':channel: :time',
'publishing_now' => 'Wordt nu gepubliceerd',
@@ -95,7 +95,7 @@
'preview' => ':name heeft je bericht teruggezet naar concepten.',
'heading' => 'Je bericht is niet goedgekeurd',
'body' => ':name heeft je bericht in de workspace :workspace teruggezet naar concepten.',
- 'channels' => 'Kanalen',
+ 'channel' => 'Kanaal',
'button' => 'Bekijken in concepten',
],
diff --git a/lang/nl/posts.php b/lang/nl/posts.php
index 14a26c8a9..6616a1edd 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,17 +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',
'description' => 'Weet je zeker dat je deze post wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.',
@@ -819,12 +807,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.',
@@ -906,6 +893,7 @@
'publishing_now' => 'Nu aan het publiceren',
'publishing_badge' => 'Publiceren…',
'publishing_on' => 'Publiceren op :network…',
+ 'in_google_review' => 'In beoordeling bij Google',
'retrying_at' => 'Nieuwe poging om :time',
'published_via' => 'Gepubliceerd via',
'published_directly_from' => 'Rechtstreeks gepubliceerd vanuit :network',
@@ -992,8 +980,6 @@
],
'group' => [
'channels' => 'Kanalen (:count)',
- 'collapse' => 'Kanalen verbergen',
- 'expand' => 'Kanalen tonen',
],
'approvals' => [
'badge' => 'Goedkeuring',
diff --git a/lang/nl/webhooks.php b/lang/nl/webhooks.php
index 9d72b78c6..b7a48786f 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/mail.php b/lang/pl/mail.php
index a79d36e8a..8b9f963e9 100644
--- a/lang/pl/mail.php
+++ b/lang/pl/mail.php
@@ -68,7 +68,7 @@
'preview' => ':name prosi o zatwierdzenie w :workspace.',
'heading' => 'Post czeka na Twoje zatwierdzenie',
'body' => ':name (:email) prosi o zatwierdzenie w przestrzeni roboczej :workspace.',
- 'channels' => 'Kanały',
+ 'channel' => 'Kanał',
'requested_time' => 'Wybrany czas',
'next_queue_slot' => 'Następne miejsce w kolejce',
'as_soon_as_approved' => 'Gdy tylko zostanie zatwierdzony',
@@ -82,7 +82,7 @@
'preview' => ':name zatwierdził(a) Twój post w :workspace.',
'heading' => 'Twój post został zatwierdzony',
'body' => ':name zatwierdził(a) Twój post w przestrzeni roboczej :workspace.',
- 'channels' => 'Kanały',
+ 'channel' => 'Kanał',
'goes_out' => 'Zostanie opublikowany',
'channel_time' => ':channel: :time',
'publishing_now' => 'Publikowanie teraz',
@@ -95,7 +95,7 @@
'preview' => ':name przeniósł(a) Twój post z powrotem do wersji roboczych.',
'heading' => 'Twój post nie został zatwierdzony',
'body' => ':name przeniósł(a) Twój post w przestrzeni roboczej :workspace z powrotem do wersji roboczych.',
- 'channels' => 'Kanały',
+ 'channel' => 'Kanał',
'button' => 'Zobacz w wersjach roboczych',
],
diff --git a/lang/pl/posts.php b/lang/pl/posts.php
index 4e97a465a..24374ca05 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,17 +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',
'description' => 'Czy na pewno chcesz usunąć ten post? Tej operacji nie można cofnąć.',
@@ -819,12 +807,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.',
@@ -906,6 +893,7 @@
'publishing_now' => 'Trwa publikacja',
'publishing_badge' => 'Publikowanie…',
'publishing_on' => 'Publikowanie w :network…',
+ 'in_google_review' => 'W weryfikacji przez Google',
'retrying_at' => 'Ponowna próba o :time',
'published_via' => 'Opublikowano przez',
'published_directly_from' => 'Opublikowano bezpośrednio w :network',
@@ -992,8 +980,6 @@
],
'group' => [
'channels' => 'Kanały (:count)',
- 'collapse' => 'Ukryj kanały',
- 'expand' => 'Pokaż kanały',
],
'approvals' => [
'badge' => 'Zatwierdzenie',
diff --git a/lang/pl/webhooks.php b/lang/pl/webhooks.php
index f139643fb..d32f9f7ab 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/mail.php b/lang/pt-BR/mail.php
index f436b5e1f..b02f9b783 100644
--- a/lang/pt-BR/mail.php
+++ b/lang/pt-BR/mail.php
@@ -68,7 +68,7 @@
'preview' => ':name pediu aprovação em :workspace.',
'heading' => 'Um post precisa da sua aprovação',
'body' => ':name (:email) pediu aprovação no workspace :workspace.',
- 'channels' => 'Canais',
+ 'channel' => 'Canal',
'requested_time' => 'Horário pedido',
'next_queue_slot' => 'Próximo horário da fila',
'as_soon_as_approved' => 'Assim que for aprovado',
@@ -82,7 +82,7 @@
'preview' => ':name aprovou seu post em :workspace.',
'heading' => 'Seu post foi aprovado',
'body' => ':name aprovou seu post no workspace :workspace.',
- 'channels' => 'Canais',
+ 'channel' => 'Canal',
'goes_out' => 'Vai ser publicado',
'channel_time' => ':channel: :time',
'publishing_now' => 'Publicando agora',
@@ -95,7 +95,7 @@
'preview' => ':name devolveu seu post para os rascunhos.',
'heading' => 'Seu post não foi aprovado',
'body' => ':name devolveu seu post do workspace :workspace para os rascunhos.',
- 'channels' => 'Canais',
+ 'channel' => 'Canal',
'button' => 'Ver nos rascunhos',
],
diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php
index 7e33d6867..7d6856cab 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,17 +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',
'description' => 'Tem certeza que deseja excluir este post? Esta ação não pode ser desfeita.',
@@ -819,12 +807,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.',
@@ -906,6 +893,7 @@
'publishing_now' => 'Publicando agora',
'publishing_badge' => 'Publicando…',
'publishing_on' => 'Publicando no :network…',
+ 'in_google_review' => 'Em revisão pelo Google',
'retrying_at' => 'Nova tentativa às :time',
'published_via' => 'Publicado via',
'published_directly_from' => 'Publicado diretamente pelo :network',
@@ -992,8 +980,6 @@
],
'group' => [
'channels' => 'Canais (:count)',
- 'collapse' => 'Ocultar canais',
- 'expand' => 'Mostrar canais',
],
'approvals' => [
'badge' => 'Aprovação',
diff --git a/lang/pt-BR/webhooks.php b/lang/pt-BR/webhooks.php
index 516405a11..e8a67001f 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/mail.php b/lang/ru/mail.php
index 460ff9651..7b7f5714a 100644
--- a/lang/ru/mail.php
+++ b/lang/ru/mail.php
@@ -68,7 +68,7 @@
'preview' => ':name запросил(а) одобрение в :workspace.',
'heading' => 'Публикация ждёт вашего одобрения',
'body' => ':name (:email) запросил(а) одобрение в рабочем пространстве :workspace.',
- 'channels' => 'Каналы',
+ 'channel' => 'Канал',
'requested_time' => 'Запрошенное время',
'next_queue_slot' => 'Следующее место в очереди',
'as_soon_as_approved' => 'Сразу после одобрения',
@@ -82,7 +82,7 @@
'preview' => ':name одобрил(а) вашу публикацию в :workspace.',
'heading' => 'Ваша публикация одобрена',
'body' => ':name одобрил(а) вашу публикацию в рабочем пространстве :workspace.',
- 'channels' => 'Каналы',
+ 'channel' => 'Канал',
'goes_out' => 'Будет опубликована',
'channel_time' => ':channel: :time',
'publishing_now' => 'Публикуется сейчас',
@@ -95,7 +95,7 @@
'preview' => ':name вернул(а) вашу публикацию в черновики.',
'heading' => 'Ваша публикация не одобрена',
'body' => ':name вернул(а) вашу публикацию в рабочем пространстве :workspace в черновики.',
- 'channels' => 'Каналы',
+ 'channel' => 'Канал',
'button' => 'Посмотреть в черновиках',
],
diff --git a/lang/ru/posts.php b/lang/ru/posts.php
index 694b1245b..7c0d0b927 100644
--- a/lang/ru/posts.php
+++ b/lang/ru/posts.php
@@ -533,7 +533,6 @@
'publishing' => 'Публикуется',
'retrying' => 'Повторная попытка',
'published' => 'Опубликован',
- 'partially_published' => 'Частично опубликован',
'failed' => 'Ошибка',
],
@@ -639,17 +638,6 @@
'flags' => 'Флаги',
],
- 'status' => [
- 'pending' => 'В ожидании',
- 'scheduled' => 'Запланирован',
- 'published' => 'Опубликован',
- 'publishing' => 'Публикация...',
- 'retrying' => 'Повторная попытка...',
- 'failed' => 'Ошибка',
- 'pending_review' => 'На проверке в Google',
- 'rejected' => 'Отклонено',
- ],
-
'delete_modal' => [
'title' => 'Удалить пост',
'description' => 'Вы уверены, что хотите удалить этот пост? Это действие нельзя отменить.',
@@ -819,12 +807,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' => 'Платформа оставалась недоступной после нескольких попыток. Попробуйте позже.',
@@ -906,6 +893,7 @@
'publishing_now' => 'Публикуется сейчас',
'publishing_badge' => 'Публикация…',
'publishing_on' => 'Публикация в :network…',
+ 'in_google_review' => 'На проверке в Google',
'retrying_at' => 'Повтор в :time',
'published_via' => 'Опубликовано через',
'published_directly_from' => 'Опубликовано напрямую из :network',
@@ -992,8 +980,6 @@
],
'group' => [
'channels' => 'Каналы (:count)',
- 'collapse' => 'Скрыть каналы',
- 'expand' => 'Показать каналы',
],
'approvals' => [
'badge' => 'Одобрение',
diff --git a/lang/ru/webhooks.php b/lang/ru/webhooks.php
index 26e55594b..9520180e5 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/mail.php b/lang/tr/mail.php
index 7132aa7b0..840cf93a9 100644
--- a/lang/tr/mail.php
+++ b/lang/tr/mail.php
@@ -68,7 +68,7 @@
'preview' => ':name, :workspace içinde onay istedi.',
'heading' => 'Bir gönderi onayınızı bekliyor',
'body' => ':name (:email), :workspace çalışma alanında onay istedi.',
- 'channels' => 'Kanallar',
+ 'channel' => 'Kanal',
'requested_time' => 'İstenen saat',
'next_queue_slot' => 'Kuyruktaki bir sonraki zaman',
'as_soon_as_approved' => 'Onaylanır onaylanmaz',
@@ -82,7 +82,7 @@
'preview' => ':name, :workspace içindeki gönderinizi onayladı.',
'heading' => 'Gönderiniz onaylandı',
'body' => ':name, :workspace çalışma alanındaki gönderinizi onayladı.',
- 'channels' => 'Kanallar',
+ 'channel' => 'Kanal',
'goes_out' => 'Yayınlanacağı zaman',
'channel_time' => ':channel: :time',
'publishing_now' => 'Şimdi yayınlanıyor',
@@ -95,7 +95,7 @@
'preview' => ':name gönderinizi taslaklara geri taşıdı.',
'heading' => 'Gönderiniz onaylanmadı',
'body' => ':name, :workspace çalışma alanındaki gönderinizi taslaklara geri taşıdı.',
- 'channels' => 'Kanallar',
+ 'channel' => 'Kanal',
'button' => 'Taslaklarda görüntüle',
],
diff --git a/lang/tr/posts.php b/lang/tr/posts.php
index fd34b50c6..d45bd2a3a 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,17 +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',
'description' => 'Bu gönderiyi silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.',
@@ -821,12 +809,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.',
@@ -908,6 +895,7 @@
'publishing_now' => 'Şimdi yayınlanıyor',
'publishing_badge' => 'Yayınlanıyor…',
'publishing_on' => ':network üzerinde paylaşılıyor…',
+ 'in_google_review' => 'Google incelemesinde',
'retrying_at' => ':time itibarıyla yeniden denenecek',
'published_via' => 'Yayınlandığı yer:',
'published_directly_from' => 'Doğrudan :network üzerinden yayınlandı',
@@ -994,8 +982,6 @@
],
'group' => [
'channels' => 'Kanallar (:count)',
- 'collapse' => 'Kanalları gizle',
- 'expand' => 'Kanalları göster',
],
'approvals' => [
'badge' => 'Onay',
diff --git a/lang/tr/webhooks.php b/lang/tr/webhooks.php
index 93b31db4e..1ee9a6719 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/mail.php b/lang/uk/mail.php
index 31d55e206..bea618b5e 100644
--- a/lang/uk/mail.php
+++ b/lang/uk/mail.php
@@ -68,7 +68,7 @@
'preview' => ':name запросив(-ла) схвалення в :workspace.',
'heading' => 'Публікація чекає на ваше схвалення',
'body' => ':name (:email) запросив(-ла) схвалення в робочому просторі :workspace.',
- 'channels' => 'Канали',
+ 'channel' => 'Канал',
'requested_time' => 'Запитаний час',
'next_queue_slot' => 'Наступне місце в черзі',
'as_soon_as_approved' => 'Щойно його схвалять',
@@ -82,7 +82,7 @@
'preview' => ':name схвалив(-ла) вашу публікацію в :workspace.',
'heading' => 'Вашу публікацію схвалено',
'body' => ':name схвалив(-ла) вашу публікацію в робочому просторі :workspace.',
- 'channels' => 'Канали',
+ 'channel' => 'Канал',
'goes_out' => 'Буде опубліковано',
'channel_time' => ':channel: :time',
'publishing_now' => 'Публікується зараз',
@@ -95,7 +95,7 @@
'preview' => ':name повернув(-ла) вашу публікацію до чернеток.',
'heading' => 'Вашу публікацію не схвалено',
'body' => ':name повернув(-ла) вашу публікацію в робочому просторі :workspace до чернеток.',
- 'channels' => 'Канали',
+ 'channel' => 'Канал',
'button' => 'Переглянути в чернетках',
],
diff --git a/lang/uk/posts.php b/lang/uk/posts.php
index bad7442df..4783f3423 100644
--- a/lang/uk/posts.php
+++ b/lang/uk/posts.php
@@ -533,7 +533,6 @@
'publishing' => 'Публікується',
'retrying' => 'Повторна спроба',
'published' => 'Опубліковано',
- 'partially_published' => 'Частково опубліковано',
'failed' => 'Помилка',
],
@@ -639,17 +638,6 @@
'flags' => 'Прапори',
],
- 'status' => [
- 'pending' => 'Очікує',
- 'scheduled' => 'Заплановано',
- 'published' => 'Опубліковано',
- 'publishing' => 'Публікується...',
- 'retrying' => 'Повторна спроба...',
- 'failed' => 'Помилка',
- 'pending_review' => 'На перевірці в Google',
- 'rejected' => 'Відхилено',
- ],
-
'delete_modal' => [
'title' => 'Видалити пост',
'description' => 'Ви впевнені, що хочете видалити цей пост? Цю дію не можна скасувати.',
@@ -819,12 +807,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' => 'Платформа залишалася недоступною після кількох спроб. Спробуйте пізніше.',
@@ -906,6 +893,7 @@
'publishing_now' => 'Публікується зараз',
'publishing_badge' => 'Публікація…',
'publishing_on' => 'Публікація в :network…',
+ 'in_google_review' => 'На перевірці в Google',
'retrying_at' => 'Повтор о :time',
'published_via' => 'Опубліковано через',
'published_directly_from' => 'Опубліковано напряму з :network',
@@ -992,8 +980,6 @@
],
'group' => [
'channels' => 'Канали (:count)',
- 'collapse' => 'Сховати канали',
- 'expand' => 'Показати канали',
],
'approvals' => [
'badge' => 'Схвалення',
diff --git a/lang/uk/webhooks.php b/lang/uk/webhooks.php
index 7fe440849..5538af5a4 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/mail.php b/lang/zh/mail.php
index 40b514692..53162b6de 100644
--- a/lang/zh/mail.php
+++ b/lang/zh/mail.php
@@ -68,7 +68,7 @@
'preview' => ':name 在 :workspace 中请求审批。',
'heading' => '有帖子需要你的审批',
'body' => ':name(:email)在工作区 :workspace 中请求审批。',
- 'channels' => '频道',
+ 'channel' => '频道',
'requested_time' => '请求的时间',
'next_queue_slot' => '队列中的下一个时段',
'as_soon_as_approved' => '获批后立即发布',
@@ -82,7 +82,7 @@
'preview' => ':name 在 :workspace 中批准了你的帖子。',
'heading' => '你的帖子已获批准',
'body' => ':name 在工作区 :workspace 中批准了你的帖子。',
- 'channels' => '频道',
+ 'channel' => '频道',
'goes_out' => '发布时间',
'channel_time' => ':channel::time',
'publishing_now' => '正在发布',
@@ -95,7 +95,7 @@
'preview' => ':name 已将你的帖子退回草稿。',
'heading' => '你的帖子未获批准',
'body' => ':name 已将你在工作区 :workspace 中的帖子退回草稿。',
- 'channels' => '频道',
+ 'channel' => '频道',
'button' => '在草稿中查看',
],
diff --git a/lang/zh/posts.php b/lang/zh/posts.php
index 3f037c285..4de091b98 100644
--- a/lang/zh/posts.php
+++ b/lang/zh/posts.php
@@ -533,7 +533,6 @@
'publishing' => '发布中',
'retrying' => '重试中',
'published' => '已发布',
- 'partially_published' => '部分发布',
'failed' => '已失败',
],
@@ -639,17 +638,6 @@
'flags' => '旗帜',
],
- 'status' => [
- 'pending' => '等待中',
- 'scheduled' => '已排期',
- 'published' => '已发布',
- 'publishing' => '发布中…',
- 'retrying' => '重试中…',
- 'failed' => '已失败',
- 'pending_review' => 'Google 审核中',
- 'rejected' => '已拒绝',
- ],
-
'delete_modal' => [
'title' => '删除帖子',
'description' => '确定要删除此帖子吗?此操作无法撤销。',
@@ -819,12 +807,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' => '多次重试后平台仍不可用。请稍后再试。',
@@ -906,6 +893,7 @@
'publishing_now' => '正在发布',
'publishing_badge' => '发布中…',
'publishing_on' => '正在发布到 :network…',
+ 'in_google_review' => 'Google 审核中',
'retrying_at' => '将于 :time 重试',
'published_via' => '发布于',
'published_directly_from' => '直接从 :network 发布',
@@ -992,8 +980,6 @@
],
'group' => [
'channels' => '频道 (:count)',
- 'collapse' => '隐藏频道',
- 'expand' => '显示频道',
],
'approvals' => [
'badge' => '审批',
diff --git a/lang/zh/webhooks.php b/lang/zh/webhooks.php
index 796ca6141..176f305c8 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/maizzle/components/channel-card.html b/maizzle/components/channel-card.html
new file mode 100644
index 000000000..c31a0f3b4
--- /dev/null
+++ b/maizzle/components/channel-card.html
@@ -0,0 +1,7 @@
+
diff --git a/maizzle/components/channel-list.html b/maizzle/components/channel-list.html
deleted file mode 100644
index f8e017c46..000000000
--- a/maizzle/components/channel-list.html
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-@foreach({!! channelsExpression !!} as $emailChannel)
-
-@endforeach
diff --git a/maizzle/templates/post-approval-requested.html b/maizzle/templates/post-approval-requested.html
index 53c68618a..8359b1bac 100644
--- a/maizzle/templates/post-approval-requested.html
+++ b/maizzle/templates/post-approval-requested.html
@@ -12,10 +12,12 @@
@{{ $postExcerpt !== '' ? $postExcerpt : __('mail.post_approval_requested.post_without_text') }}
-
- @{{ __('mail.post_approval_requested.channels') }}
-
-
+ @if($channel)
+
+ @{{ __('mail.post_approval_requested.channel') }}
+
+
+ @endif
@{{ __('mail.post_approval_requested.requested_time') }}
diff --git a/maizzle/templates/post-approved.html b/maizzle/templates/post-approved.html
index c61af75cc..bf32b1063 100644
--- a/maizzle/templates/post-approved.html
+++ b/maizzle/templates/post-approved.html
@@ -8,28 +8,18 @@
@{{ __('mail.post_approved.body', ['name' => $approverName, 'workspace' => $workspaceName]) }}
- @if(count($goesOutPerChannel) > 0)
+ @if($channel)
- @{{ __('mail.post_approved.goes_out') }}
-
- @foreach($goesOutPerChannel as $goesOut)
-
-
- @{{ $goesOut['at'] ?? __('mail.post_approved.publishing_now') }}
-
- @endforeach
- @else
-
- @{{ __('mail.post_approved.channels') }}
-
-
-
- @{{ __('mail.post_approved.goes_out') }}
-
-
- @{{ $goesOutAt ?? __('mail.post_approved.publishing_now') }}
+ @{{ __('mail.post_approved.channel') }}
+
@endif
+
+ @{{ __('mail.post_approved.goes_out') }}
+
+
+ @{{ $goesOutAt ?? __('mail.post_approved.publishing_now') }}
+
diff --git a/maizzle/templates/post-note-added.html b/maizzle/templates/post-note-added.html
index bca2762fc..1dff3aa8e 100644
--- a/maizzle/templates/post-note-added.html
+++ b/maizzle/templates/post-note-added.html
@@ -17,8 +17,8 @@
@{{ __('mail.post_note_added.post_title') }}
@{{ $postExcerpt !== '' ? $postExcerpt : __('mail.post_note_added.post_without_text') }}
- @if(count($channels) > 0)
-
+ @if($channel)
+
@endif
diff --git a/maizzle/templates/post-publish-failed.html b/maizzle/templates/post-publish-failed.html
index 341120b4d..736f22f40 100644
--- a/maizzle/templates/post-publish-failed.html
+++ b/maizzle/templates/post-publish-failed.html
@@ -8,13 +8,11 @@
@{{ __('mail.post_publish_failed.body', ['workspace' => $workspaceName]) }}
- @if($publication)
-
- @if($publication['error'])
-
- @{{ $publication['error'] }}
-
- @endif
+
+ @if($publication['error'])
+
+ @{{ $publication['error'] }}
+
@endif
diff --git a/maizzle/templates/post-published.html b/maizzle/templates/post-published.html
index 62b5e7a5b..fd2bdedd0 100644
--- a/maizzle/templates/post-published.html
+++ b/maizzle/templates/post-published.html
@@ -8,9 +8,7 @@
@{{ __('mail.post_published.body', ['workspace' => $workspaceName]) }}
- @if($publication)
-
- @endif
+
diff --git a/maizzle/templates/post-rejected.html b/maizzle/templates/post-rejected.html
index 63a42965d..3c7f40476 100644
--- a/maizzle/templates/post-rejected.html
+++ b/maizzle/templates/post-rejected.html
@@ -8,10 +8,12 @@
@{{ __('mail.post_rejected.body', ['name' => $approverName, 'workspace' => $workspaceName]) }}
-
- @{{ __('mail.post_rejected.channels') }}
-
-
+ @if($channel)
+
+ @{{ __('mail.post_rejected.channel') }}
+
+
+ @endif
diff --git a/resources/js/components/ChannelConfigurator.vue b/resources/js/components/ChannelConfigurator.vue
index 615a46436..3f4750a5a 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)),
);
@@ -179,6 +176,7 @@ const settingsIndex = (channel: Channel): number =>
:video-duration-sec="videoDurationSec"
:content-type="channel.contentType"
:meta="channel.meta"
+ :platform-index="settingsIndex(channel)"
:disabled="disabled"
@update:meta="updateMeta(channel, $event)"
/>
@@ -191,6 +189,7 @@ const settingsIndex = (channel: Channel): number =>
:boards="channel.boards ?? []"
:boards-truncated="channel.boardsTruncated ?? false"
:meta="channel.meta"
+ :platform-index="settingsIndex(channel)"
:disabled="disabled"
@update:meta="updateMeta(channel, $event)"
/>
@@ -217,6 +216,7 @@ const settingsIndex = (channel: Channel): number =>
v-else-if="channel.platform === Platform.Discord"
:social-account="channel.socialAccount"
:meta="channel.meta"
+ :platform-index="settingsIndex(channel)"
:disabled="disabled"
@update:meta="updateMeta(channel, $event)"
/>
diff --git a/resources/js/components/ConfirmDeleteModal.vue b/resources/js/components/ConfirmDeleteModal.vue
index 5f36f9cc0..09650a5b7 100644
--- a/resources/js/components/ConfirmDeleteModal.vue
+++ b/resources/js/components/ConfirmDeleteModal.vue
@@ -183,7 +183,11 @@ defineExpose({