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.
LinkedInPageAnalytics and MastodonAnalytics were the only two of the 11
files refactored to read OAuth host / default instance from config that
had zero test coverage. Adds smoke tests that assert the HTTP request
hits the configured URL, so a typo in the config key (e.g. linkedin.api
vs linkedin.oauth_api) would now fail loudly.
Earlier in the PR the new configs (linkedin.oauth_api, youtube.oauth_api,
bluesky.default_service, mastodon.default_instance) were only read by
ConnectionVerifier. The same URLs were still hardcoded in the publishers,
analytics and the Bluesky auth controller — meaning a self-hosted user
setting BLUESKY_DEFAULT_SERVICE or MASTODON_DEFAULT_INSTANCE in env would
get split behavior: refresh/verify honor the override, publish/analytics
don't.
Routes all 10 remaining call sites through the same config values so the
overrides actually work end-to-end.
Review follow-ups:
- verifyMastodon was the last hardcoded host left after the PR moved
LinkedIn/YouTube/Bluesky to config. Adds trypost.platforms.mastodon
.default_instance (env MASTODON_DEFAULT_INSTANCE) and reads from it.
- refreshToken() docblock now declares @throws PlatformUnavailableException
(the whole point of the PR was missing from its contract).
- Strip the new explanatory comments inside catch blocks and tests —
rationale lives in the commit / PR, not inline. The two comments
inside empty `catch (TokenExpiredException) {}` blocks stay because
there the comment is the only thing telling the reader why the
exception is swallowed.
When a provider's API was down (5xx, timeout, DNS), the hourly
RefreshSocialToken job and daily VerifyWorkspaceConnections job were
treating it as "token revoked" and emailing the user to reconnect.
Bluesky going offline triggered false-positive disconnect notifications
because Bluesky access tokens are short-lived (2h) so every hourly
refresh failed during the outage.
- New PlatformUnavailableException: API unreachable / 5xx, transient.
TokenExpiredException stays for 4xx (token is provably bad).
- New TokenRefreshClient: normalizes failure semantics for OAuth
refresh HTTP calls across all providers. Takes a Platform enum so
typos fail at compile time and the user-facing label comes from
one source.
- ConnectionVerifier: all 8 refresh*Token methods route through the
new client. Hardcoded OAuth URLs (LinkedIn, YouTube) and Bluesky's
default PDS host moved into config/trypost.php alongside the
existing per-platform entries.
- RefreshSocialToken job: PlatformUnavailableException → log warning
and stop. Do NOT markAsTokenExpired, do NOT notify the user. Next
scheduled tick retries.
- VerifyWorkspaceConnections job: PlatformUnavailableException from
the inner refresh propagates and is treated as a transient skip.
The /assets page (and the picker dialog that reuses GalleryBrowser)
posted files in a single multipart request, so anything past nginx /
php-fpm's body limit failed with 413. The chunked endpoint and util
already existed but were only wired into an orphan composable.
- GalleryBrowser now always uploads via uploadChunked
- Drop the orphan useMediaManager composable
- Drop shouldUseChunkedUpload (no longer referenced)
Show.vue already renders a full-screen overlay with spinner + the same
'post is being published' messaging while post.status === 'publishing'.
The flash toast was saying the same thing transiently — duplicate UX
that also contributed to the visual noise as Echo events triggered
partial reloads.
- Remove session()->flash() for the Publishing action in PostController
- Drop the now-orphan 'flash.publishing' key from en/pt-BR/es
Scheduled-action flash kept (Show.vue has no equivalent overlay for it).
- PinterestSettings: replace inline error markup with the shared
InputError component (matches the rest of the form patterns)
- Extract PinterestBoard type to @/types and use it across
PinterestSettings, ScheduleTab, PostEditorSidebar, Edit — drops
the duplicated inline {id, name} shape from 4 call sites
- Block publish/schedule when Pinterest is selected but no board is
picked (mirrors tiktokComplianceValid). Tooltip surfaces the same
i18n key the backend uses, so frontend + backend speak the same
language
The post editor lost the Pinterest board picker during a UI rewrite,
causing scheduled posts to fail in production with 'Pinterest board_id
is required'. This restores the picker and locks the contract with
validation + tests so the regression cannot happen silently again.
Backend:
- PostController: pinterestBoards is now Record<account_id, Board[]>
(mirrors the TikTok creator-info pattern); supports multi-account.
- UpdatePostRequest: 'platforms.*.meta.board_id' rule + after-validator
rejects Publishing/Scheduling Pinterest posts without board_id.
Frontend:
- PinterestSettings.vue: Combobox board picker with empty-state warning;
emits update:meta with board_id.
- ScheduleTab / PostEditorSidebar / Edit pass pinterestBoards down by
social_account_id.
Tests (6 new):
- UpdatePostRequestTest: rejects publishing/scheduling without board_id
across pin/carousel/video pin; allows draft without board_id;
pinterest error doesn't block sibling platforms in multi-platform.
- PinterestPublisherTest: publisher throws for carousel + video pin
when no board_id (existing image-pin case kept).
1542 tests passing.
- StripeEventListener::handleSubscriptionCreated nulls account.trial_ends_at
when a Stripe subscription is created. Prevents 'Trial' badge from
lingering for users who convert mid-generic-trial to paid.
- Drop unused trialDays global Inertia prop (no frontend consumers after
/subscribe redesign).
Tests added (10 new, 0 regressions, 1533 total):
- AccountTest: isOnTrial + activeTrialEndsAt across 4 scenarios
(no trial, generic only, subscription only, both — subscription wins)
- StripeEventListenerTest: subscription created clears generic trial
- TrialMiddlewareAccessTest: trialing-with-card subscription passes
- BillingControllerTest: index exposes onTrial/trialEndsAt for the 3
trial states (generic-only, subscription-only, paying); subscribe
page no longer exposes trialDays prop
Trial now exists exclusively at signup (no-card generic trial). The
/subscribe page is reached only after the trial has been consumed (or to
upgrade plans), so it should never offer another trial — that would be a
double-trial loophole.
- BillingController::subscribe drops trialDays prop
- BillingController::checkout drops ->trialDays() call (direct charge)
- Subscribe.vue drops trialDays prop and :days placeholders
- i18n (en/pt-BR/es): new subscribe-focused copy, remove start_trial/trial_info
Centralizes 'what date should the UI show as trial end?' on the model.
Returns null when not on trial, the subscription's trial date when on
trial-with-card, or the generic trial date for no-card users.
Centralizes the 'is the user on any kind of trial?' question in the model.
Equivalent behavior — Cashier's subscribed() already includes trialing
subscriptions — but semantically cleaner and easier to extend.
- BillingController::index reads onTrial from Account::isOnTrial() (covers
generic trial without a Stripe subscription) and falls back to
account.trial_ends_at when no subscription exists. Vue page already had
the badge + 'Trial ends' UI wired — just needed the right props.
- Drop default trial_days from 8 to 7 for consistency with messaging.
New signups land on a 7-day generic trial (Cashier trial_ends_at) without
a Stripe customer or subscription. Account is on Starter plan limits during
the trial. After 7 days, EnsureAccountReady redirects to /subscribe per the
existing flow.
- CreateUser sets account.trial_ends_at and plan_id = Starter
- EnsureAccountReady allows access when subscribed() OR onGenericTrial()
- Account::isOnTrial() includes generic trial check
Existing users unaffected: paying users have a subscription;
never-paid users continue redirecting to /subscribe.
The LinkedIn Page connection has a two-step OAuth: first the
`callback` stashes the Socialite user in `linkedin_page_pending` and
redirects to the page picker, then `select` finalizes by writing the
chosen organization to social_accounts. The pending payload was missing
`approved_scopes`, and both finalize paths (`update` for reconnect,
`updateOrCreate` for first connect) never wrote the `scopes` column.
Result: every LinkedIn Page account had `scopes = NULL` in the DB,
the publish-time scope check saw `w_organization_social` as missing
and blocked every post with 'Missing permissions. Please reconnect
your account.'
Fix: stash `approved_scopes` in the session payload, then in both
finalize paths persist it with the same comma-split treatment used by
the LinkedIn personal controller (the LinkedIn-OpenID provider has the
same separator quirk — granted scopes come CSV-joined inside a
single Socialite array element).
Test: `linkedin page select splits comma-separated approvedScopes
before saving` covers the persist + split path.
Adds an explicit test asserting that when a workspace has multiple
LinkedIn users (e.g., the owner plus a client teammate), syncing
tokens from one user's accounts never touches accounts admin'd by a
different LinkedIn user — even though all live in the same workspace.
The behavior is already correct by virtue of the
`admin_user_id` / `platform_user_id` match in the synchronizer's
query, but the property is now part of the test contract instead of
emergent.
LinkedIn and Pinterest's OAuth providers return the granted scope list
joined by comma (LinkedIn) or space-in-one-element (Pinterest), but
Socialite's scope splitter doesn't match either, so 'approvedScopes'
lands as a single-element array containing the whole list:
LinkedIn: ['email,openid,profile,r_basicprofile,w_member_social']
Pinterest: ['boards:read boards:write pins:read pins:write user_accounts:read']
That breaks the publish-time scope check in PublishToSocialPlatform
(array_diff does exact string compare), surfacing as
'Missing permissions: w_member_social. Please reconnect your account'
even though the scopes were actually granted at the provider.
Fix is inline at each callback — re-split before saving. Each provider
has its own quirk (LinkedIn = comma, Pinterest = space), so each
controller handles its own separator.
Tests added: callback splits the joined approvedScopes into individual
tokens for both providers.
Same semantics, more idiomatic Laravel. Drops the (array) cast,
the array_values+array_filter pair, and the if (!empty(...)) guard
in favor of $request->collect() + Collection pipeline +
$query->when() conditional clause.
Adds a combobox-style filter to the posts index toolbar so users can
narrow All / Scheduled / Posted / Drafts views by one or more labels.
- `PostController::index` accepts `?labels[]=<id>` and applies
`whereHas('labels', whereIn(...))` (OR semantics across selected labels).
Workspace labels are exposed to the page (sorted by name) and the
selected set comes back under `filters.labels`.
- New `LabelFilter.vue` component reuses the existing Popover + Command
pattern (matching `FontPicker` in the Brand settings page). Trigger
renders the selected `LabelBadge`s inline (mirroring how each post row
already displays its labels): 1-3 shown directly, 4+ shown as the
first three plus a "+N" overflow indicator. Clear button has a
tooltip and `cursor-pointer`, and stops `click`/`pointerdown`/
`mousedown` so it doesn't reopen the Popover.
- Existing search debounce is shared with the new label watcher via a
single `buildFilterUrl` helper. URL is updated with `preserveState +
replace` so the back stack stays clean.
- i18n in en / pt-BR / es: `filter_by_label`, `label_search_placeholder`,
`no_labels`, `clear_label_filter`.
Tests: 4 new index tests covering the labels prop exposure, single-label
filter, multi-label OR filter, and blank-id sanitization. Full suite:
1509 passed, 2 skipped, 0 failed.
Add /app/ (Pusher protocol) and /apps/ (REST API) location blocks
with Upgrade/Connection headers so browsers can connect to the
in-container Reverb server via the same port 80/443 endpoint.
- Add libwebp-dev + libavif-dev and --with-webp --with-avif to GD
configure (fixes imagewebp() undefined at runtime)
- Merge wayfinder-gen into asset-build stage: PHP + Node together
so @laravel/vite-plugin-wayfinder can invoke artisan during build
- Pass VITE_* as build args and ENV so they bake into the bundle
- Stub APP_URL=http://localhost so artisan boot doesn't crash on null
- mkdir -p storage/framework/{cache/data,sessions,views} storage/logs
bootstrap/cache before artisan runs (dirs stripped by .dockerignore)
- Production stage: rm vendor + bootstrap/cache/*.php before swapping
to prod-only deps (prevents missing-provider errors from dev cache)
- Switch production chown from app:app to www-data:www-data to match
Alpine php-fpm default pool user
User-facing documentation lives in trypostit/trypost-docs. Per
maintainer feedback, the Docker setup walkthrough belongs there
rather than inline in this repo's README, which is meant to stay
focused on product positioning. Compose stack and docker/ assets
stay in place.
Two adjustments from the PR review.
Stripe placeholders (STRIPE_KEY, STRIPE_SECRET, STRIPE_WEBHOOK_SECRET,
and the eight price IDs) only matter when SELF_HOSTED=false. The
Docker stack defaults to SELF_HOSTED=true, so keeping the block in
.env.docker.example just adds noise for first-time contributors.
upload_max_filesize and post_max_size go from 100M to 1G in both
php.dev.ini and php.prod.ini to accept full-length video uploads.
Bumping nginx client_max_body_size to match — otherwise the proxy
would cut the request off before PHP ever sees it.