Audit found three behaviors with no explicit assertion:
- Job is dispatched with a 10-minute delay (would silently regress if
the duration changed). Uses Carbon::setTestNow + Bus::assertDispatched
inspecting \$job->delay.
- error_context.last_attempt_at is recorded at the moment of failure.
- updatePostStatus does NOT finalize the parent Post while any of its
platforms is in Retrying — covers the central invariant of the
feature (the post must stay Publishing until every platform lands
in Published or Failed).
- A platform currently in Retrying transitions to Published when the
next attempt succeeds — proves the loop terminates.
Before: a scheduled post hitting a platform outage was marked Failed —
user had to manually retry. Now the job reschedules itself for 10
minutes later and the PostPlatform shows status "Retrying". Loops
indefinitely until the platform accepts the post.
- Adds PostPlatformStatus::Retrying (existing string column, no migration)
- PublishToSocialPlatform: PlatformUnavailable catch now calls
rescheduleForRetry() which (a) updates the row to Retrying with
retry_count + next_attempt_at in error_context, and (b) dispatches
itself with a 10-minute delay. updatePostStatus() naturally leaves
the parent Post in Publishing because Retrying is neither Published
nor Failed.
- Same treatment for the retry-refresh edge case (publisher throws
TokenExpired, refresh subsequently fails with PlatformUnavailable).
- i18n + frontend status config updated (en, pt-BR, es) for both
posts.status.retrying and posts.edit.status.retrying.
- Tests: 3 new tests covering the dispatched job, the edge case path,
and retry_count increment across attempts.
Edge case from the prior commits: if the publisher throws TokenExpired
(401 path), PublishToSocialPlatform attempts refreshAccountToken() to
recover. That internally goes through ConnectionVerifier::verify, which
can now raise PlatformUnavailable (5xx). The old catch (\Throwable)
swallowed it but the loop still fell through to markAsTokenExpired —
meaning a transient platform outage during a retry could still flip the
account to expired.
Adds an explicit PlatformUnavailable catch in the retry block: marks
the post failed with category platform_unavailable and breaks before
touching the account status.
The previous commits in this PR closed the loophole on the hourly /
daily token-refresh jobs. The same loophole remained on the publish
path: every per-platform publisher (LinkedIn, X, YouTube, TikTok,
Threads, Instagram, Pinterest, Bluesky and their Analytics siblings)
has its own refreshToken() called before publishing a scheduled post,
and all of those treated any non-2xx as TokenExpired — including 5xx.
Result before this commit: a Bluesky outage that coincided with a
scheduled publish would mark the account as expired and fail the post.
Changes:
- Route every refreshToken() in the 16 publisher / analytics classes
through TokenRefreshClient::for(Platform::X)->send(...).
- TokenRefreshClient now also fills platformErrorCode from the HTTP
status and pulls error_description / error.message from the JSON
body, preserving the richer info LinkedIn / X / TikTok / Pinterest /
Threads used to put on their TokenExpiredException.
- PublishToSocialPlatform catches PlatformUnavailableException
explicitly: the post is marked failed (category: platform_unavailable,
with http_status in error_context) but the account stays Connected.
No retry inside this job — the scheduler reattempts the next run.
Test added: publish flow does NOT mark account expired when the
publisher throws PlatformUnavailable. Full suite: 1569 passing.
Three related fixes for the failure mode where a scheduled post errors out
as 'An unknown X error occurred.' when a social account's refresh_token
was already invalidated by the provider:
1. **PublishToSocialPlatform**: fail-fast when account status is
`TokenExpired`. Previously the job tried to publish, the publisher
internally tried to refresh, the provider rejected the rotated
refresh_token, and the failure surfaced as a generic 'unknown' error
instead of a clear 'reconnect your account' signal.
2. **XPublisher::refreshToken**: when the OAuth endpoint rejects the
refresh_token (typically because it was rotated/revoked at X), log the
raw response and throw `TokenExpiredException` instead of falling
through to `XPublishException::fromApiResponse` which expects the
tweet-API response shape (`type`/`title`/`detail`) and treats
OAuth-style responses (`error`/`error_description`) as 'Unknown'.
3. **SocialAccount::markAsTokenExpired**: dispatch an in-app + email
notification (`Type::AccountDisconnected`) when an account
transitions from `Connected` → `TokenExpired`, mirroring the
existing pattern in `markAsDisconnected`. Wrapped in a lock to
prevent duplicate notifications on concurrent transitions. Accepts an
optional `notify: false` so the batch verifier
(`VerifyWorkspaceConnections`) can suppress per-account
notifications and rely on its summary email.
- Create Account model as Cashier Billable entity (stripe, plan, subscription)
- Account owns workspaces and has an owner_id (User)
- User belongs to one Account via account_id
- Workspace belongs to Account via account_id, no longer has billing fields
- Remove Brand model entirely (workspaces serve as grouping)
- Rename brand_limit to workspace_limit in plans
- Workspace roles simplified: admin/member/viewer (owner via Account)
- Invites now belong to Account with workspaces JSON array
- Pennant features scope changed from Workspace to Account
- EnsureSubscribed middleware checks Account subscription
- All controllers updated: BillingController, OnboardingController,
WorkspaceInviteController, SocialController, StripeEventListener
- Frontend: extract GoogleAuthButton component, create WorkspaceRole
enum for type-safe role checks, fix all views for new architecture
- All 1101 tests passing
- Fix UpdatePostRequest missing content_type, synced, meta fields
(content_type was silently dropped, causing Instagram Reels to post as Feed)
- Create API FormRequests (StorePostRequest, UpdatePostRequest) replacing inline validation
- Fix syntax errors in all publishers ($media->isVideo() missing variable)
- Fix Instagram Feed with single video calling publishSingleImage instead of publishReel
- Fix TikTok hardcoded SELF_ONLY privacy — now queries creator_info API
- Refactor YouTubePublisher to use google/apiclient SDK with chunked resumable upload
- Fix all publishers using file_get_contents for large videos (memory overflow)
— X, LinkedIn, LinkedInPage, Pinterest, Bluesky, Mastodon now use temp file + stream
- Fix Media::isVideo/isImage to use mime_type instead of extension
- Fix Threads not saving refresh_token (was null, now saves access_token)
- Add Instagram token refresh to publisher and ConnectionVerifier
- Fix PublishToSocialPlatform job: tries 3→1 (prevents duplicate uploads),
timeout 60→600s, added failed() method for cleanup
- Increase Horizon worker timeout 60→630s, Redis retry_after 90→660s
- Increase upload limit 500MB→1GB
- Add mastodon to getDefaultContentType in Edit.vue
- Refactor WorkspacePolicy to use pivot role instead of workspace.user_id
- Add manageBilling policy (owner only) to BillingController
- Fix ApiKeyController authorization (view → manageTeam for store/destroy)
- Fix WorkspaceInviteController using workspace.user_id for owner checks
- Fix WorkspaceController settings is_owner using workspace.user_id
- Create PostAction enum for UpdatePost/PostController action strings
- Create ApiToken\Status enum
- Add User::SUBSCRIPTION_NAME constant, replace all hardcoded 'default'
- Convert wantsEmailFor to accept NotificationType enum
- Convert all $data[] to data_get() across publishers, controllers, jobs
- Fix SocialLoginController callback missing try/catch
- Fix SocialController::toggleActive missing workspace null check
- Fix UpdatePost NPE on meta merge when postPlatform not found
- Remove HTML5 required attributes from form inputs
- Convert function declarations to arrow functions in Vue components
- Replace hardcoded URLs with Wayfinder route helpers
- Replace new Date() with dayjs
- Add 16 new test files covering policies, authorization, publishing
Settings pages:
- Redesign layout to match Sendkit (max-w-4xl, space-y-12, Separator sections)
- Merge Members page into Workspace settings with Table, invite Dialog, ConfirmDeleteModal
- Add workspace logo upload/delete routes and controller methods
- Translate all hardcoded strings in Workspace.vue modals
Language system:
- Drop languages table, replace language_id FK with locale string column on users
- Create config/languages.php for available languages and default locale
- Add Spanish (es) translations (13 files)
- Simplify HandleInertiaRequests, ProfileController, RegisteredUserController
Code quality:
- Add declare(strict_types=1) to all PHP files
- Fix MastodonPublisher using wrong attribute (filename -> original_filename)
- Fix HasMediaTest for new has_photo/photo_url accessors
- Fix PublishToSocialPlatformTest type error revealed by strict_types
- Remove orphaned Language model from AppServiceProvider morph map
- Update User TypeScript interface (has_photo, photo_url, locale)
- Eager load media relation on workspaces to prevent N+1
- Add 8 new tests for workspace logo upload/delete
- Update workspace settings test to assert members/invitations props
All 710 tests passing.
- Extract business logic from controllers into Action classes:
Post/, Workspace/, Hashtag/, Label/, Invite/, ApiKey/
- Create subdomain routing: app.trypost.test (Inertia dashboard),
api.trypost.test (REST API with token auth)
- Add ApiToken model with tp_ prefix, token_lookup/hash auth
- Add AuthenticateApiToken middleware for API authentication
- Create Api controllers with JSON Resources for all entities
- Create App controllers that use Actions + Inertia responses
- Organize Form Requests into Api/ and App/ directories
- Add api_tokens migration
- Update all route names with app. prefix
- Update all tests to use new route names (684 passing)