Skip to content

Merge post_platforms into posts - #397

Merged
paulocastellano merged 54 commits into
mainfrom
feat/merge-post-platforms-into-posts
Oct 9, 2026
Merged

paulocastellano merged 54 commits into
mainfrom
feat/merge-post-platforms-into-posts

Conversation

@paulocastellano

@paulocastellano paulocastellano commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Why

TryPost 2.0 made every post independent per channel, but the data still lived in two tables: posts and post_platforms, with exactly one destination per post. This PR moves the destination onto the post row and drops post_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*)

  1. Prepare. Runs in one transaction on both engines. Clears what the 2.0 split left behind: disabled destinations that never published, unpublished posts without a destination (turned into drafts), published posts without a destination (deleted with their media, quietly, as a disconnect does), and partially_published posts with one destination. It stops with an explanation (pointing to v2.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.
  2. Add columns. The destination columns land on posts: social_account_id, publish_status, platform, content_type, meta, the channel snapshot, the publication fields, publication_updated_at, legacy_target_id. Indexes and analytics_publications.post_id are added too.
  3. Backfill. Chunked and idempotent. It copies every destination, links analytics publications by post, and replaces post.partially_published webhook subscriptions with post.published + post.failed. It finishes with a full parity check (counts, scalar columns, JSON, tenancy).
  4. Drop. Removes analytics_publications.post_platform_id and post_platforms, after a guard that every row was copied.

Model

  • posts.status stays the lifecycle. posts.publish_status (App\Enums\Post\PublishStatus) is the network side.
  • partially_published is removed.
  • Publication writes go through Post::markPublication*() / writePublication(). These move publication_updated_at and leave updated_at alone.
  • Channel-less legacy drafts are kept and stay editable. Scheduling or publishing one asks for a channel (posts.errors.choose_channel).

Removed

  • PostPlatform, its factory, observer and resource.
  • The postPlatform morph alias.
  • The one-off 2.0 release commands: 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)

  • Lists and calendar are unchanged. The publish page tabs, month and week calendars and channel pages of all 262 workspaces were extracted with main before 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.
  • Publishing is unchanged. 44 real scheduled posts (23 network and content-type pairs) were published by main and 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.
  • Write flows work with real data: composer on two channels, queue next/top/reorder, edit, duplicate, delete, recurrence, publish now, notes, approval request/approve/reject with their emails, idea to post with media.
  • A Google Business post waiting for Google's review now reads "In review by Google" on its card and in its details.

⚠️ Breaking: API, MCP and webhooks (no compatibility shape)

  • Post object: no platforms[]. The top level carries publish_status, social_account, platform, content_type, meta, platform_url, error_message and display_*.
  • Create / update input: top-level social_account_id / content_type / meta. Sending platforms is a 422.
  • Validation errors: single-post writes report meta.* / content_type. Batches keep destinations.{i}.*.
  • Preview and post metrics: one object each. Analytics returns post_id instead of post_platform_id. Each repurpose item post carries platform + publish_status.
  • Webhooks: channel fields at the top of the post.* payload, with no error_context. post.partially_published is removed, and stored subscriptions are migrated.

DEPLOYMENT.md §4 lists the docs.trypost.it pages to update.

Before merging

Deploy (see DEPLOYMENT.md)

  • Maintenance + full queue drain. Includes delayed jobs, so no old job carrying a destination id survives.
  • Build before 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.
  • Forge script: add artisan horizon:terminate after $RESTART_QUEUES().
  • Rehearse migrate on a production copy first. The first migration tells you if anything still needs posts:split-legacy-active.
  • Self-hosted on v1.1.0: must upgrade through v2.0.0 and run release:trypost-2 first.
  • Forward-fix only after up.

Tests

  • Full suite on PostgreSQL: 9,484 passed, 0 failed.
  • MySQL: Migrations, Posts, Commands, Analytics, Jobs, Api, Parity, Mcp, Services and Unit, about 6,250 passed, 0 failed.
  • Browser suite: 704 passed.
  • New: merge migration tests (guard, cleanup, backfill of every column, publish time, analytics links, webhook events, parity corruption, re-runs, drop guard), Post publication model tests, a web edit without content_type.
  • Deleted, because the behaviour no longer exists:
    • tests of posts with several destinations, disabled destinations and partially_published;
    • tests of the removed release commands;
    • old migration tests that need post_platforms at runtime (ClearGiphySource, CollapseLinkedInContentTypes, RemoveAspectRatioMeta, RemoveChannelPause, PublishLegacyTargetMedia).

Paulo Castellano 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.
Paulo Castellano 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.
Paulo Castellano 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.
@paulocastellano
paulocastellano merged commit cb73e41 into main Oct 9, 2026
10 checks passed
@paulocastellano
paulocastellano deleted the feat/merge-post-platforms-into-posts branch October 9, 2026 23:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant