Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
f810007
Merge post_platforms into posts: schema, model and application code
Oct 8, 2026
c7c6cb1
Show one channel per post card and update copy for single-channel posts
Oct 8, 2026
9a63f3f
Test the merge migrations and document the single-channel model
Oct 8, 2026
14e0ed0
Convert the test suite to single-channel posts and fix what it found
Oct 9, 2026
9e0371f
Drop the partially published note from list-posts-tool and require th…
Oct 9, 2026
9b63225
Roll back the merge preparation when its guard stops
Oct 9, 2026
a7af3de
Delete published posts that lost their channel before the merge
Oct 9, 2026
4104178
Keep the publish time posts already show when merging destinations
Oct 9, 2026
619a753
Show when Google is reviewing a Google Business post
Oct 9, 2026
314f0cd
Address the pre-production review of the posts merge
Oct 9, 2026
d2d6f30
Drop legacy_target_id: link the merge by post id instead
Oct 9, 2026
a51202f
Settle the final review of the posts merge and use the status enums
Oct 9, 2026
c2f6725
Cover the scenarios the fourth review found without tests
Oct 9, 2026
21180ca
Apply the final standards review of the posts merge
Oct 9, 2026
0b4f124
Open the post details when a sent post card is clicked
Oct 9, 2026
2e11f53
Show the pointer on every post card and open details for any non-edit…
Oct 9, 2026
4a3d8a5
Open a post card through one handler that edits or shows details
Oct 9, 2026
80432cb
Keep the channels rail in place while the post details scroll
Oct 9, 2026
92729f5
Keep delete confirmations a centered card on phones
Oct 9, 2026
b7f2bf6
Match the week grid add button to the height of its slot chips
Oct 9, 2026
b1557dd
Highlight the week grid add button like the active view on hover
Oct 9, 2026
c23c8e8
Drop the add button border on hover, like the active view
Oct 9, 2026
e3f58bc
Give the week grid add button the dashed look of the slot chips
Oct 9, 2026
37f3326
Give the week grid add button the default button hover
Oct 9, 2026
08aa24b
Start the previous period arrow on the calendar's edge
Oct 9, 2026
e910cd2
Tighten the calendar toolbar so the period title has room
Oct 9, 2026
3c1e1f2
Narrow the gap between the calendar period and its filters
Oct 9, 2026
599be28
Collapse the calendar filters into one menu below 2xl
Oct 9, 2026
c1efb0f
Collapse the calendar filters only when the period title would be cut
Oct 9, 2026
fab9cf0
Move the calendar toolbar fit into a typed composable
Oct 9, 2026
2857833
Keep the post details channels rail always open
Oct 9, 2026
624bff8
Stack posts that share a week hour as compact rows
Oct 9, 2026
1a5796b
Switch calendar views with tabs on desktop
Oct 9, 2026
0840cd6
Size the calendar view tabs like the Today button
Oct 9, 2026
a6a79f9
Bring back the calendar view dropdown on desktop
Oct 9, 2026
1d77977
Scroll an expanded month day inside its cell
Oct 9, 2026
6f9213a
Fix the calendar browser tests after the toolbar and month-day changes
Oct 9, 2026
bed4635
Simplify how SyncOwnedMedia writes reply media back
Oct 9, 2026
04767a5
Show the post note email's single channel without a list
Oct 9, 2026
12363fb
Let ContentTypeMatchesPlatform find the sibling account itself
Oct 9, 2026
acf4dde
Use blank/filled for null checks and one sanitizer call per publisher
Oct 9, 2026
3d783bd
Send one approval email per post
Oct 9, 2026
4a7ba46
Read the approval time zone from the post's channel
Oct 9, 2026
c7f8a1e
Drop the post card's testKey alias
Oct 9, 2026
32557cb
Read settings meta errors by destination index
Oct 9, 2026
a61c3c3
Remove the unused web post metrics route
Oct 9, 2026
6e62897
Tidy PostObserver and the selected account lookup
Oct 9, 2026
657e709
Show the failed email's channel whenever the post has one
Oct 9, 2026
9aedbdd
Drop checks the published and failed emails can never fail
Oct 9, 2026
febd2b3
Simplify SyncOwnedMedia's reply handling
Oct 9, 2026
bcaeb25
Name the in-flight publish statuses on the enum
Oct 9, 2026
36a9343
Key a batch's reply media errors by their destination
Oct 9, 2026
003f97e
Email every workspace member when a post publishes or fails
Oct 9, 2026
5b13c1e
Notify the workspace members without a separate owner lookup
Oct 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
4 changes: 2 additions & 2 deletions .ai/rules/google-business.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 2 additions & 2 deletions .ai/rules/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
2 changes: 1 addition & 1 deletion .ai/rules/jobs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 2 additions & 2 deletions .ai/rules/media.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 2 additions & 2 deletions .ai/rules/migrations.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
---
paths:
- 'app/Jobs/PostHog/**'
- app/Models/PostPlatform.php
- app/Models/Post.php
- 'database/migrations/**'
- app/Models/SocialAccount.php
---

# 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).
Expand Down
2 changes: 1 addition & 1 deletion .ai/rules/post-hog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
12 changes: 6 additions & 6 deletions .ai/rules/post.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading