Repository navigation
Merge post_platforms into posts - #397
Merged
Merged
Conversation
added 6 commits
October 8, 2026 20:00
Every post now carries its single destination (social account, content type, meta) and its publication result (publish_status, platform post id, URL, error, checkpoints) on its own row. The migrations resolve the legacy rows TryPost 2.0's split left behind, refuse to run while a post still has several destinations, copy each destination onto its post, link analytics publications by post, and drop post_platforms. Code that only existed for posts with several destinations is removed instead of ported, together with the one-off TryPost 2.0 release scripts.
Post cards, the calendar, post details and the failure popover read the channel and publication from the post itself. Edits report meta errors under meta.*, the partially published status and webhook event are gone, and saving a channel-less draft asks for a channel first.
Covers the guard, the legacy cleanup, the backfill (every column, publish time, analytics links, webhook events, parity, re-runs) and the drop. The deploy guide and the project rules now describe posts that carry their own channel.
Every test now builds posts that carry their own channel. Tests of multi-destination posts, disabled destinations, partially published posts and the removed 2.0 release commands are deleted. Fixes found while converting: - single-post writes (API and MCP create, every update) report errors under the post's own keys (meta.*, content_type), not destinations.0.* - the edit composer shows thread reply, Pinterest, Discord and TikTok errors keyed meta.* - a web edit without content_type keeps the stored type again - importing media for an imported post no longer reads an undefined variable - the analytics publication factory writes post_id
MySQL does not wrap migrations in a transaction, so the legacy cleanup was committed before the guard threw. The preparation now runs in one transaction on both engines.
added 23 commits
October 9, 2026 09:30
The production copy has 139 published posts without any destination. They are history whose channel no longer exists, so the preparation deletes them with their media (files removed after commit), quietly, the way disconnecting a channel does.
The backfill took the destination's publish time, which is a second or so before the time the post shows, so 57 cards on the production copy moved. A published post now keeps its own time and falls back to the destination's only when it has none.
A Google Business post waiting for Google's review now reads "In review by Google" on its card and in its details, instead of "Publishing on Google Business". Before, that label only appeared next to each destination of a post with several channels, which no post has any more.
- The merge migrations no longer call app code: orphan media rows are deleted with the query builder and their files removed after commit, only when no other row uses them; the column default is a literal. - Destinations whose account is gone are dropped before the merge, so their posts follow the same rules as posts without a destination. - The backfill refuses destinations that are switched off or lost their account, and the drop refuses analytics not linked to their post. - Mastodon keeps the old destination id in its idempotency key for merged posts, so a retry right after the deploy cannot post twice. - A publishing post whose channel is gone fails right away. - A draft without an account is recoverable in the composer, and thread reply errors show when editing. - Google review hides the progress sweep; the repurpose list labels in-review and rejected posts. - MCP descriptions, README and project rules no longer mention removed commands or platforms[]; dead code is removed.
Every post has at most one destination after the preparation, so the backfill, its parity check and the drop guard link a destination to its post through post_platforms.post_id; the parity check now also refuses a post with two destinations. Google Business derivatives and Mastodon idempotency keys are named after the post again: the deploy drains the queues and checks that no Google review is open, so nothing still depends on the old destination ids. The publish page reads the post's channel directly.
- A partially published post left without a destination is deleted like a published one instead of stopping the migration. - A Google review whose channel is gone is left to the review ceiling; only a publication that never reached the network fails right away. - Rejected repurpose posts are dimmed like failed ones. - Post status lists in validation, MCP schemas and the composer use the Status enum instead of strings. - The deploy guide limits the delayed-job wait to the queues that carry old payloads, warns about failed jobs and notes the MySQL interval syntax.
Reconnecting across a network variant moves only the posts still waiting to publish; a post without a channel left publishing is failed once by the recovery sweep; the failure email shows the account and the reason when Google rejects a post; disconnecting deletes a post waiting for a network limit so no retry runs; linking a sent post to its publication moves only the publication clock and queues no sync.
- Post cards, details, the failure popover and the undated panel read the post's single channel directly instead of one-element lists. - Edit media errors land on the item they belong to when the network card has its own media. - The publish_status column default references the enum, as the migration rules require; closure parameters in the merge migrations are typed. - Display accessors use Attribute::make like the rest of the model; HostInlineMedia reads with data_get; the jobs rule no longer mentions disabled destinations.
Clicking a published or failed post on the sent tab opens its details, the same dialog the card menu opens; editable posts still open the composer.
Dialogs fill the screen on phones; a delete confirmation is a short question, so ConfirmDeleteModal opts out through a new fullscreenOnMobile option on DialogContent.
The calendar toolbar did not fit next to the sidebar on laptop widths, so its status, time zone and posting-times controls move into the filter menu below 1536px (PublishFilterMenu takes a compactBelow breakpoint; the list keeps md). The label filter stays a button of its own next to channels so it is never lost.
The toolbar measures itself instead of a screen breakpoint: when the period title stops fitting it moves status, time zone and posting times into the filter menu, remembers the width it needed, and shows them again once the toolbar is that wide. PublishFilterMenu takes the compact decision from the calendar; the list keeps md.
added 25 commits
October 9, 2026 17:02
useCollapseWhenTruncated holds the measure-collapse-expand logic, the period picker exposes how much its title lacks through a template ref instead of the page querying a test id, and PublishFilterMenu's compact prop is boolean | null with null meaning the md breakpoint.
The rail is the only way to move between the posts created together, so its collapse button, expand button and their labels are gone on desktop and phones.
Side by side, two or three posts in the same hour squeezed into unreadable slivers. They now stack as the month view's compact rows (icon, time, thumbnail); a lone post keeps its full card, and more than three show two rows and a +N that opens the hour in place.
The Week/Month dropdown becomes a segmented control of links, styled like the List/Calendar switch, so the views are one click apart. Phones keep the period popover.
The tabs take the List/Calendar switch's measures (32px track, 24px tabs), matching Today beside them.
The Week/Month tabs read worse than the dropdown they replaced.
Opening +N more grew the list over the next week. The expanded list now fills the room left in the day and scrolls there, with Show less kept at the bottom of the cell.
The expanded day list's test id no longer starts with calendar-day-, so lookups by that prefix still find the day. The filter-collapse test checks a width where the period title cannot fit, since the toolbar now collapses on measured space rather than a breakpoint.
Reply media is synced with array_map and the kept ids are read once from the root and reply items through replyMedia(), shared with the lookup at the top. The owner is saved with update(), since media and meta are fillable, and the reply error key uses interpolation.
A post has one channel, so the mailable passes it (or null for a draft without one) and the template renders one card instead of feeding a one-item list to the shared channel-list component.
The account is always next to the content type (top level or inside a destination), so the rule derives that path instead of taking an attribute name from the post rules.
Null checks on models, enums, ids, dates and strings added by the merge read through blank() and filled(); checks on arrays stay strict, since an empty array means something different there (an empty channel filter, an empty error context). Every publisher repeated content ? sanitize(content, platform) : null. ContentSanitizer::forPost() holds it once, and the publishers that send an empty string add ?? ''.
Posts created together for several channels are separate posts, each approved on its own, so the approval request goes to every approver once per post and each decision emails the requester as soon as it commits. The post_group_id grouping, its cache and the delayed SendApprovalDecisionEmail job are gone; SendNotification drops a request whose post is no longer pending and a decision whose post was deleted. The approval emails and the post note email render their one channel through a shared channel-card Maizzle component, which replaces channel-list, and the label reads Channel in all 16 locales. PublishStatus::isFailure() names the failed-or-rejected check used by the publish-failed email.
ApprovePostButton wrapped the post's one account in a list to reuse the composer's multi-channel zone rule. A post has one channel, so the zone is that channel's, else the user's.
It was card_key ?? id when one post could render several cards; with one channel per post it only repeated post.id, which the template now uses directly.
DiscordSettings, TikTokSettings and PinterestSettings matched any key
ending in .meta.<field>, so with two accounts of one network in a batch
an error on one showed on both. They now take platformIndex like the
YouTube and Google Business panels, and all five read their errors
through useMetaErrors: destinations.{index}.meta.* in a batch, meta.* in
an edit.
No page called app.posts.metrics: the cards and the details dialog read post.metrics from the page props, and the chart button opens the channel insights. The route, its controller action and its tests go; the API and MCP metrics stay, and the parity test now compares them with the metrics the publish page sends.
PostObserver names its steps (wasJustPublished, syncPublicationAnalytics, syncPublishingActivity), reports analytics errors through rescue(), and reads the previous status with getOriginal(), which is still the old, cast value in saved; getRawOriginal() never returned an enum, so that branch was dead. PostRequestRules::selectedAccounts() drops the is_string check that Str::isUuid() already does and reads through the workspace relation. PersistedPostMetricsReadTest still called the removed web metrics route; it reads the API and MCP metrics instead.
The email is only sent for a failed or rejected publication, or for a post whose channel vanished (marked failed when it has a destination), so checking the publish status again changed nothing. The channel card now depends on hasDestination() alone, and PublishStatus::isFailure(), used only there, is gone.
A post without a channel only exists as a draft (it cannot be scheduled or published, and the merge turned the rest into drafts), so the failed email, the published email and FinalizePostPublication always see a post with a destination, and the published email is only sent once the publication is published. Both mailables pass their channel and both templates render it without the dead @if.
The submitted items are collected once for the id and upload token
lookups, the replies are synced with collect()->map() so the index
comes with each one, and the reply error key is a single
Str::replaceLast('media', 'meta.thread_replies', ...). A test pins the
error key of a missing reply media on an edit.
PublishStatus::isInFlight() (pending, publishing or retrying, the statuses isClosed() leaves out) replaces RecoverStuckPosts' IN_FLIGHT constant and the same list written out in FinalizePostPublication; the in-flight-or-in-review check before finalizing is ! isFinished().
SyncOwnedMedia derived the reply media error key from the root media
key, which is the shared media when a destination does not override
it, so a batch reported destinations.{i} reply errors as
meta.thread_replies.* and the composer could not show them under the
reply. PostCompositionValidator now hands each destination its own
meta key (destinations.{i}.meta) and SyncOwnedMedia keys reply errors
under it; single-post writes still report meta.thread_replies.*
through forSinglePost().
The published and failed emails went to the workspace owner only. They now go to every member and the owner, once each; SendNotification still checks each user's notification preferences.
Only the account owner creates workspaces, CreateWorkspace attaches them as an admin member and the owner cannot be removed, so the members already include the owner: notify() reads members only and the workspace.owner eager load goes. The test workspaces that expected the owner's email now attach the owner as a member, as the app does.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
TryPost 2.0 made every post independent per channel, but the data still lived in two tables:
postsandpost_platforms, with exactly one destination per post. This PR moves the destination onto the post row and dropspost_platforms, so there is one place to read and write a post's channel and publication result.What changes
Schema (4 migrations,
2026_10_08_2229*)partially_publishedposts with one destination. It stops with an explanation (pointing tov2.0.0+release:trypost-2) when a post still has more than one destination, a disabled destination published, a published post has no destination, or a destination's account belongs to another workspace.posts:social_account_id,publish_status,platform,content_type,meta, the channel snapshot, the publication fields,publication_updated_at,legacy_target_id. Indexes andanalytics_publications.post_idare added too.post.partially_publishedwebhook subscriptions withpost.published+post.failed. It finishes with a full parity check (counts, scalar columns, JSON, tenancy).analytics_publications.post_platform_idandpost_platforms, after a guard that every row was copied.Model
posts.statusstays the lifecycle.posts.publish_status(App\Enums\Post\PublishStatus) is the network side.partially_publishedis removed.Post::markPublication*()/writePublication(). These movepublication_updated_atand leaveupdated_atalone.posts.errors.choose_channel).Removed
PostPlatform, its factory, observer and resource.postPlatformmorph alias.release:trypost-2,posts:split-legacy-active,posts:audit-legacy,posts:purge-orphaned,posts:bake-aspect-ratio-crops,analytics:backfill-existing.Rehearsal on the production copy (2026-10-09)
mainbefore migrating and with this branch after. 15,900 cards match field by field (status, times, channel, network, content type, text, media, labels). The only differences are the 139 deleted orphans (and the pages they shift), the failed post that became a draft, and posts that share the exact same second, which the database returns in either order on both branches.mainand by this branch against the same faked network responses. Both sent the same requests (URL and body), ended in the same status with the same ids and errors, and sent the same emails.platforms[]. The top level carriespublish_status,social_account,platform,content_type,meta,platform_url,error_messageanddisplay_*.social_account_id/content_type/meta. Sendingplatformsis a 422.meta.*/content_type. Batches keepdestinations.{i}.*.post_idinstead ofpost_platform_id. Each repurpose item post carriesplatform+publish_status.post.*payload, with noerror_context.post.partially_publishedis removed, and stored subscriptions are migrated.DEPLOYMENT.md§4 lists the docs.trypost.it pages to update.Before merging
v2.0.0on the currentmain(311767990). The latest tag isv1.1.0, and self-hosted installs needv2.0.0to runrelease:trypost-2, which this PR removes.Deploy (see
DEPLOYMENT.md)migrate. On the production copy of 2026-10-09 (23,120 posts, 68,022 destinations) the four migrations took 12–75 s (the 75 s run removed orphan media files synchronously; with Redis that is queued): 45,520 disabled destinations dropped, 1 failed post turned into a draft, 139 published posts without a channel deleted (236 media), every other destination copied with parity.artisan horizon:terminateafter$RESTART_QUEUES().migrateon a production copy first. The first migration tells you if anything still needsposts:split-legacy-active.v1.1.0: must upgrade throughv2.0.0and runrelease:trypost-2first.up.Tests
content_type.partially_published;post_platformsat runtime (ClearGiphySource,CollapseLinkedInContentTypes,RemoveAspectRatioMeta,RemoveChannelPause,PublishLegacyTargetMedia).