Conflict resolutions + integration fixes:
- CreatePost: kept the branch's merge-into-existing meta persistence (equivalent
to main's #86 replace on create, and what the automations flow was built on).
- FacebookSettings.vue: kept both new defaults (previewOnly + meta).
- RunGenerateNode + GenerateNodeConfig.vue: ContentType::InstagramCarousel was
removed on main (#80); an IG carousel is now a multi-image instagram_feed, so
the carousel-capable list uses InstagramFeed.
- GenerateNodeTest: fixtures use the ContentType enum and the new instagram_feed
carousel signal.
Review follow-ups:
- AspectRatio::toFloat() now owns the crop ratio math; CropsImageForAspectRatio
delegates to it so the enum is the single source of truth for both validation
and cropping (no more parallel literal map).
- API update controller now reloads postPlatforms before returning, so the
update response reflects the persisted platform meta/content_type (was stale).
- Tests: AspectRatio enum unit test; API valid-update read-back + 'original'
on create; MCP response read-back + valid update.
PR #82 made `meta.aspect_ratio` crop Facebook (and already Instagram) feed
images at publish time, but the API and MCP surfaces only half-supported it:
you couldn't set meta at creation, the value wasn't validated, and responses
never returned it. This closes those gaps.
- New `AspectRatio` enum is the single source of truth for the allowed ratios
(1:1, 4:5, 16:9, original). App/API/MCP requests now validate via
`Rule::enum(AspectRatio::class)` — an invalid ratio is rejected everywhere
instead of silently center-cropping to square.
- API `StorePostRequest` and MCP `CreatePostTool` now accept `platforms.*.meta`;
`CreatePost` persists it. MCP create documents `meta` in its schema.
- `Api\PostPlatformResource` now exposes `meta`, so API and MCP responses return
the aspect_ratio (and other per-platform meta) a client set.
Replace the additive LINKEDIN_EXTRA_SCOPES approach with a single
full-override env var per flow, exploded into an array at the config
layer (config/trypost.php -> platforms.linkedin{,-page}.scopes). This
keeps env values as plain comma-separated strings, lets self-hosters
override the entire set in one place, and removes the controller-side
scope-merge logic.
- config/trypost.php: explode LINKEDIN_SCOPES / LINKEDIN_PAGE_SCOPES
into the scopes arrays (deprecated r_basicprofile stays out of the
personal default)
- LinkedInController: drop resolveScopes(), read config scopes directly
- LinkedInPageController: drop the hardcoded $scopes property, read
config scopes at both call sites
- tests: drive the connect scope assertions from config overrides
- .env.example, docker/.env.docker.example: document LINKEDIN_SCOPES
and LINKEDIN_PAGE_SCOPES
Consolidate the default scope set alongside the existing LinkedIn host
config under config/trypost.php -> platforms.linkedin, matching the
project convention that per-platform service config lives there. The
default still drops the deprecated r_basicprofile scope, and
LINKEDIN_EXTRA_SCOPES stays additive (merged onto the defaults rather
than replacing them) so operators can opt back into legacy scopes
without risking a misconfigured full-replacement.
- config/trypost.php: add scopes + extra_scopes to platforms.linkedin
- config/services.php: drop the moved extra_scopes key
- LinkedInController::resolveScopes(): read both from trypost config
- tests: repoint config() overrides to the new key
Make r_basicprofile opt-in via LINKEDIN_EXTRA_SCOPES so self-hosted users
unblock by default and ops with legacy/enterprise products keep working.
Why
---
LinkedIn rejects OAuth authorize requests with a generic "Bummer,
something went wrong" page when an app asks for a scope it can't grant.
`r_basicprofile` is a legacy scope deprecated in 2018; new LinkedIn dev
apps don't have it, so every self-hosted user hits the rejection
immediately on `/connect/linkedin`.
The two products LinkedIn actually grants to standard apps today are:
- Sign In with LinkedIn using OpenID Connect → `openid profile email`
- Share on LinkedIn → `w_member_social`
That set is enough for the connect flow. The only piece of data
`r_basicprofile` was buying us is `/v2/me`'s `vanityName` (pretty
`linkedin.com/in/<slug>`). `fetchVanityName()` already handles HTTP
failure gracefully (returns null), and the only downstream consumer —
`LinkedInPagePublisher`'s post-URL builder — already falls back to a
numeric `linkedin.com/feed/update/<id>` URL when `$account->username`
is null.
Backward compatibility
----------------------
Ops with legacy or enterprise LinkedIn products approved on their dev
app (so they DO have `r_basicprofile`) can opt back in via env:
LINKEDIN_EXTRA_SCOPES=r_basicprofile
`LinkedInController::resolveScopes()` merges this comma-separated list
into the default scope array. The connect flow's `Socialite::scopes()`
call then includes the legacy scope, preserving the pre-PR behaviour
end-to-end (including `vanityName` lookup).
Net effect for users without `r_basicprofile`:
- Connect flow works (was previously rejected by LinkedIn).
- Posts publish exactly the same way.
- Generated post URLs use the numeric form instead of the vanity slug.
Tests
-----
- `linkedin connect requests the default scope set when LINKEDIN_EXTRA_SCOPES is unset`
- `linkedin connect appends LINKEDIN_EXTRA_SCOPES to the default scope set`
- Existing `splits comma-separated approvedScopes` fixture updated to
match the new default set.
Instagram carousels were stored as content_type=instagram_carousel, which the
publisher's match() did not handle — publishing failed with "Unsupported
Instagram content type: instagram_carousel" for any post created via API, MCP,
or template (the AI flow worked only thanks to an inline band-aid that rewrote
carousel to feed before saving).
A carousel is just an Instagram feed post with multiple images: the editor,
preview, and publisher already treat a multi-image feed as a carousel. So
instagram_carousel is a generation format, not a stored content type. Remove it
from the ContentType enum entirely; it now lives only as an AI generation-format
string (wizard card + slide structure + carousel templates are untouched), and
posts always persist as instagram_feed.
- ContentType: drop the InstagramCarousel case; InstagramFeed maxMediaCount 1 -> 10
- StreamPostCreation: resolvedContentType() maps carousel -> feed; band-aid removed
- StartPostCreationRequest: accept instagram_carousel as a generation format
- Frontend: carousel becomes a wizard-local AiFormat; UI/UX unchanged
- API/MCP now reject instagram_carousel as a content_type (Rule::in no longer lists it)
The Workspace and Brand settings tabs both submit to the same
updateSettings endpoint, but the Workspace tab sends only the name.
UpdateWorkspaceRequest marked brand_font and image_style as required,
so the name-only request failed validation silently (the form only
renders the name error). Mark both brand fields as sometimes|required
so a name-only update succeeds while the Brand tab still validates them.
Closes#74
- Added new automation-related routes and controllers for managing automations.
- Introduced automation nodes in the UI with distinct styles and interactions.
- Updated sidebar to include navigation for automations.
- Enhanced post creation logic to support automation metadata.
- Refactored content type and platform enums into types for better type safety.
- Added localization for automation-related terms in English, Spanish, and Portuguese.
- Improved error handling in various components to accommodate new features.
- Removed the PostStatusGuard class and replaced its usage with the new PostStatusRules utility across multiple controllers and actions, enhancing code organization and maintainability.
- Updated error message handling to utilize the centralized method in PostStatusRules, ensuring consistency in user feedback.
- Deleted associated tests for PostStatusGuard, reflecting the removal of the class.
- Replaced direct status checks in multiple controllers and actions with the PostStatusGuard utility, improving code readability and maintainability.
- Updated error messages to utilize a centralized method for consistency across the application.
- Removed the BrandImagePalette class, consolidating color resolution logic into the AiImageClient for better organization and type safety.
Let users adjust AI-generated slides in place via async job and Echo, while applying workspace brand, background, and text colors to image prompts. Autofill swaps site text/background colors for the image palette, and regeneration is blocked on finalized posts with safer job cleanup.
Co-authored-by: Cursor <cursoragent@cursor.com>
Add a trypost config toggle to switch between card-required checkout trials and no-card signup trials, and wire signup, checkout, access gating, UI copy, and tests to both modes.
Co-authored-by: Cursor <cursoragent@cursor.com>
Revert the no-card signup trial flow so access depends on a Stripe subscription trial started at checkout, preventing app access before a payment method is collected.
Co-authored-by: Cursor <cursoragent@cursor.com>
- Add separate canDelete predicate in Index.vue that includes Failed —
the previous EDITABLE_STATUSES gating hid the delete button for
failed posts even though the backend allows deleting them.
- Edit.vue Echo handler navigates to /show when an in-page real-time
status update transitions the post into a read-only state, instead
of leaving the user on a stuck readonly editor.
- FacebookPublisher: stop using empty() for content checks (treats
literal "0" as empty) — compare explicitly against null and "".
- Update terminal-state error messages in API + MCP update tool to
reflect the broadened guard (no longer Published-only). Adjust the
matching test assertion.
- PostController@edit was redirecting Failed→show while show was
redirecting Failed→edit, producing ERR_TOO_MANY_REDIRECTS. Failed
posts now render in show.
- New universal `hasContentOrMedia` rule in Edit.vue blocks publishing
when both text and media are empty (closes the hole where empty posts
could reach the publish button).
- Unified `PLATFORM_VARIANTS` to include Facebook, Instagram and
LinkedIn variants. togglePlatform snaps to a compatible variant when
reselecting a platform whose current content_type is incompatible
with the attached media (fixes the case where Reel+image left the
tile permanently blocked).
- platformIssues suppresses the issue on deselected tiles when a
compatible variant exists, so the tile remains clickable and the
snap can recover state.
- Use ContentType enum in place of string literals.
UpdatePost::execute used to return AlreadyPublished for the Published
short-circuit. This PR widened the short-circuit to four terminal
statuses and consolidated them under PostAction::Finalized — so the
old enum case stopped being emitted, and every caller already had a
defensive in_array([AlreadyPublished, Finalized], ...).
Audit before removal: nothing emits AlreadyPublished anymore (only
UpdatePost::execute returns Actions, and it returns Finalized for
the whole terminal set), no test references the case, and no string
'already_published' exists elsewhere in app/resources/tests/lang.
- Drop the enum case
- Simplify the three in_array checks to a direct === Finalized
- Delete the dead App/PostController branch that flashed the old
cannot_edit_published message (its successor branch with
cannot_edit_finalized stays). The old i18n key is left in lang/
for now — orphan but harmless, can ressuscitate if a similar
flash is added back.
Production incident: a customer's Facebook Page post failed with 'The post
is empty. Please enter a message to share.' (error code 197) and ended up
with a contradictory DB state (status=published + error_message=set).
Three independent bugs were uncovered:
A. FacebookPublisher sends 'message'/'description' as null when the user
posts media without text. Graph API requires the key be omitted, not
null. Fixed in publishSingleImagePost, publishMultiImagePost,
publishVideoPost, publishReel.
B. markAsPublished/markAsFailed leak stale fields across transitions
(a published row could retain error_message from a prior failure,
vice-versa). Both transitions now explicitly clear the opposite
side's fields.
C. status='failed' was editable in the UI and the backend, so users
were re-clicking Publish, generating duplicate failure emails and
the contradictory state from bug B. The frontend isReadOnly check
and the UpdatePost backend guard now treat Published/PartiallyPublished/
Failed/Publishing as terminal. To retry, the user duplicates the post.
11 new tests guarantee these can't regress silently: FB payload shape
per content type, PostPlatform field-clearing on transitions, and the
terminal-status block at the controller level.
Members joining via workspace invite share the owner's account_id.
ProfileController::destroy was unconditionally calling $account->delete()
in every profile-deletion path, so any member could wipe the whole
organization (cascade: workspaces, posts, social accounts, signatures,
labels) just by clicking Delete on their own profile.
Gate the account/subscription teardown behind isAccountOwner(). For
members the path now only detaches them from workspaces and deletes
the user row — owner's data is untouched.
Tested in both SELF_HOSTED=true and false.
Self-hosted installs (SELF_HOSTED=true, the default) now close /register
to the public. Workspace invites still work — the AcceptInvite page
links into /register with ?invite={id}, the middleware persists that
into the session, and POST /register passes through.
- EnsureRegistrationEnabled middleware gates GET/POST /register.
Accepts ?invite=… (URL) or pending_invite_id (session) as the pass.
- RegisteredUserController::store clears the marker after signup.
- AcceptInvite.vue passes invite.id in the register link's query string.
- Login.vue hides the "Sign up" link when self_hosted.
- UserSeeder bootstraps a single admin (admin@trypost.it / password).
Idempotent; not wired into DatabaseSeeder — operator runs
`php artisan db:seed --class=UserSeeder` per the install docs.
- Tests cover both flag values for every changed surface.
Docs PR: see trypost-docs self-hosting/installation.mdx step 3.
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.
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).
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.
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.
`$account->stripe()` can throw `\Stripe\Exception\InvalidArgumentException`
(descends from PHP's `InvalidArgumentException`, not `ApiErrorException`)
when the Stripe key is missing/malformed. Since the conversion data is
purely for tracking, any failure must degrade silently — not break the
post-payment success page.
Wire `value`, `currency`, and `transaction_id` from Stripe Checkout Session
into the `purchase` dataLayer event so GTM can fire Google Ads Conversion
Tracking with accurate per-plan revenue and deduped transaction IDs.
- `BillingController::checkout` adds `{CHECKOUT_SESSION_ID}` to success_url
- `BillingController::processing` retrieves session lazily (closure prop,
so polling partial reloads don't re-hit Stripe) and exposes
`conversion` with `value`/`currency`/`transaction_id`
- `Processing.vue` forwards `conversion` to `trackPurchase`
- `useTracking.trackPurchase` pushes `conversion_value`,
`conversion_currency`, `conversion_transaction_id` to dataLayer +
PostHog
- Platform::contentOverflow returns int (0 = fits) via max(), drops the
nullable-int + ternary pattern; HasSocialHttpClient and the validation
rule updated to compare against 0 instead of null.
- Drop the hardcoded 63206-char limit to 10000 across all entry points
(Platform enum, MCP CreatePostTool/UpdatePostTool, FacebookRules, all
three FormRequests). Facebook's API accepts up to 63206 but nobody
writes 63k-char posts and emoji-heavy content risks overflowing the
TEXT column's 65535-byte ceiling.
- Compiled i18n JSON regenerated with the content_exceeds_platform key.
Threads posts over 500 chars were saved + scheduled successfully and only
failed inside the publish job. The frontend already showed the 537|500 badge
but `canSchedule` ignored content length, so Schedule and Post Now stayed
enabled. Backend `UpdatePostRequest` only capped at 63206 (Facebook's max),
not per-platform.
- Add `Platform::contentOverflow()` as the single source of truth and reuse it
from `HasSocialHttpClient::validateContentLength` (publish-time).
- New `ContentFitsPlatformLimits` rule applied to the `content` field on
`App\\UpdatePostRequest`, `Api\\UpdatePostRequest`, and `Api\\StorePostRequest`
via `Rule::when(...)` so drafts are not blocked.
- Rule dedupes per platform (two Threads accounts -> one error) and reports
the platform label, hard cap, and overage via i18n.
- Edit.vue feeds `contentLengthOverflows` into `canSchedule` and lists each
offending platform in `postActionTooltip` using the existing
`getPlatformLabel` resolver.
Two related fixes that together eliminate the 'Loading your TikTok
account settings…' flicker users were seeing on every keystroke /
variant click in the post editor:
1. PostController::edit no longer wraps tiktokCreatorInfos in
Inertia::defer. The map is computed during the initial render and
shipped as a regular prop. Without defer, the prop never resets to
null between Inertia visits, so the loading line never reappears.
2. TikTokCreatorInfo::fetch is now wrapped in a 5-minute Cache::remember
keyed by social_account_id. Autosaves (which round-trip through
PostController::update → back() → edit() again) used to issue a
fresh TikTok API call for every connected account on every save —
now the cache short-circuits them. Creator info changes very rarely
(only when the user updates privacy settings on TikTok itself), so
five minutes of staleness is acceptable; the worst case is a
slightly out-of-date privacy-options list that corrects on next
page load.
Frontend cleanup: dropped the creatorInfoLoading prop, the inline
loading <p>, and the now-orphaned posts.form.tiktok.creator_info_loading
i18n key in en/pt-BR/es. ScheduleTab no longer passes the prop.
Removes the messages() override on UpdatePostRequest. The 4 messages it
defined were hardcoded English strings that bypassed Laravel's built-in
validation translations. With the override gone, errors flow through
lang/{locale}/validation.php which is already translated for en/pt-BR/es.
Trade-off: default messages reference the field path (e.g.
'platforms.0.content_type'). Acceptable here because the editor renders
errors inline next to each field rather than as a flat list.
- ContentType::description() now reads from posts.content_types.{value}.description
instead of hardcoded English. Adds the missing tiktok_photo entries in en/pt-BR/es
and syncs three Pinterest descriptions that had drifted between the i18n file and
the previously-hardcoded enum strings (the enum strings were the user-visible source).
- UpdatePostRequest::withValidator surfaces the privacy_required validation error
via posts.form.tiktok.privacy_required (added in en/pt-BR/es) instead of an
English string.
- Drops a verbose 3-line comment in withValidator that explained obvious code.