* Remove the automations module
Drops the visual workflow builder end to end: actions, node runners,
commands, jobs, models, observers, policy, resources, requests, routes,
broadcast channel, scheduler entries, Horizon supervisor, Vue pages and
components, canvas undo/redo history, CodeEditor, translations, factories
and tests.
A new migration rewrites posts.created_via = 'automation' to 'web' and
drops the five automation tables. The CreatedVia::Automation enum case is
removed. The 2026-08-21 social-account identity migration now skips its
automation node repointing when the automations table no longer exists,
so it stays re-runnable after the drop.
Orphaned dependencies removed: simplepie/simplepie, @vue-flow/*,
codemirror and @codemirror/*.
* Drop the orphaned common.beta translation key
* Remove automation leftovers: ResolvableUrl rule, feed fixtures, useShortcut, nav badge
* Drop the unused chart wrapper and @unovis packages
* Address review: keep the shipped migration untouched, drop the dead previewOnly chain
- Restore 2026_08_21 migration to exactly what production ran; the
rehearsal test now recreates the automations table it expects instead.
- Mark the drop migration's down() irreversible like its siblings.
- Simplify DropAutomationTablesMigrationTest to the sibling shape.
- Remove previewOnly / aiGenerateVariants: the only caller that set the
prop was the deleted automation Generate node.
- Run pint over lang/*/common.php after the beta key removal.
* Exercise the drop migration against the real automation tables and their FKs
* Drop DuplicateIdentityRehearsalTest: it re-ran a frozen migration that reads the removed automations table
* Add workspace webhooks and drop the unused automation webhook node.
Give workspaces HMAC-signed outgoing webhooks for the post lifecycle, with retry, auto-pause, replay, and live logs, and keep HTTP Request as the only outbound automation node.
* Tighten webhook controller and validation after review.
Drop the redundant workspace redirects, prune logs without counting, and validate events/status with Rule::enum.
* Move leftover webhook UI copy behind i18n.
HTTP status phrases, delete-cancel, and validation attribute names were still English literals.
* Build the webhook-paused email through Maizzle.
The hand-written Blade skipped the shared layout, header, and footer used by the other mail templates.
* Cover real webhook dispatch paths and restyle the webhook pages.
* Ask for the shared delete keyword when confirming a webhook delete.
The endpoint URL is a poor confirm string; posts and assets already use the common "delete" keyword.
* Fix webhook review blockers so CI can go green.
Drop leftover French automation keys, stop mutating Inertia log props, and show delivered_at instead of created_at.
* Close the remaining webhook review gaps.
Keep Echo log updates across infinite scroll, align the channel with the policy, persist log ids across retries, and fail unknown automation nodes without throwing.
* Stop webhook delivery after disable and record last sent only on success.
Queued jobs now skip paused or disabled endpoints unless the user replays, and changing the URL re-pings it first.
* Limit webhooks to owners and admins, and encrypt signing secrets.
Members can no longer create or inspect outgoing integrations, and secrets stay encrypted at rest.
* Cover webhook secret hiding, skip-ping, and failed-delivery edges.
* Send the full post on webhooks after labels and platforms are saved.
* Fix webhook payloads for integer media ids and type webhook status.
* Split the webhook show page into focused components.
* Reset live webhook logs when switching endpoints.
* Keep the newest webhook logs at the top after live merges.
* Cast media item ids to string without the extra scalar check.
* Add post.unscheduled webhooks and put the log id on the envelope.
Unscheduling is now a first-class event, and receivers can send the delivery id back so we can find the matching log.
* Translate webhook event names in the UI.
* Make the webhook show page full-width and stop stacking flash toasts.
* Translate remaining webhook UI copy in every locale.
* Sign webhook pings and drop author email from the payload.
* Send signed webhook tests after create instead of pinging on save.
Create and update only block private URLs so the receiver can copy the secret first. The show page then sends a signed webhook.test with an object data envelope.
* Polish webhook test UX and always mint the dispatch log id in the job.
Keep send-test in the actions menu (its own group) and drop the leftover constructor param so retries reuse the serialized id instead of a caller-supplied one.
* feat: expose self-hosted mode to the accounts UI
SocialAccountObserver already bypasses the one-account-per-network
guard when trypost.self_hosted is true, but the frontend had no way
to know that and always collapsed a network to a single card once
any account existed - so self-hosted deployments could not surface
a second LinkedIn (or Instagram) connection even though the backend
would allow creating it.
* feat: allow connecting multiple accounts per network when self-hosted
NetworkConnectGrid always collapsed a network (LinkedIn profile/page,
Instagram standalone/Facebook) to a single card once any account
existed, with no way to trigger another OAuth flow - even though
SocialAccountObserver already allows unlimited accounts per network
in self-hosted mode. A self-hoster connecting their personal LinkedIn
profile had no path back to the connect flow to also add a company
page (or a second company page/showcase page).
Render one card per connected account instead of collapsing to the
first, and keep a standing "Connect another" card available for a
network's existing connections when self-hosted. Hosted mode is
unchanged: still one card per network, matching the backend's
still-enforced one-account-per-network limit there.
* test: cover the selfHosted prop on accounts and onboarding pages
Backend behavior for connecting a second identity per network in
self-hosted mode was already covered (LinkedInControllerTest,
NetworkUniquenessTest) - these just confirm the new prop the frontend
now depends on is actually present and reflects config correctly.
* style: apply prettier formatting
Pre-existing drift in this file unrelated to the selfHosted change.
* refactor: read selfHosted from shared Inertia props
The flag is already shared by HandleInertiaRequests, so the accounts
and onboarding controllers do not need to pass it again.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat: gate multiple social accounts with a dedicated env
Cloud cannot flip SELF_HOSTED, so one-per-network is now ALLOW_MULTIPLE_SOCIAL_ACCOUNTS (default false).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: tighten multiple-account gates after review
Keep every connected identity visible, share occupiesNetwork, and return network_taken instead of a generic connect error.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: bind reconnect to the card and unique social identity
Reconnect now updates the selected account, and a unique index plus connectIdentity keep the same platform identity from being inserted twice.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: build the OAuth URL before opening the popup
Keep the popup opener URL-only so reconnect query params are assembled at the call site.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: drop dead social-account guards and slim the connect grid
Skip migration cleanup that production never needs, trust the platform enum in the observer, and move card theming out of the grid.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: scope social reconnect to the current network
Drop dead instanceof/isset guards and filter reconnect targets in the query so a stale session cannot update another network's card.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: slim connectable-identity filtering
Index OAuth identities by id so reconnect and occupancy use only/except instead of hand-rolled filters.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: slim social identity persist helpers
Drop the unused occupiesNetwork exception and persist reconnects with update() instead of fill/save/fresh.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: keep reconnect updates on the original social card
Co-authored-by: Cursor <cursoragent@cursor.com>
* test: run the suite with multiple social accounts enabled
phpunit.xml forced ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=false, overriding the
true value in .env.ci. That broke eight tests across Automation, MCP,
PostApi, RefreshExpiringTokens and VerifyUpcomingConnections which only
needed two accounts of one network as a fixture, not as a rule under test.
Match .env.ci instead. Every test that exercises the one-per-network rule
already sets the config itself; the accounts index test was the only one
leaning on the implicit default, so it now pins it.
* fix: align the multi-account fallback with the self-hosted default
allow_multiple_social_accounts fell back to env('SELF_HOSTED', false)
while self_hosted itself defaults to env('SELF_HOSTED', true). A
self-hosted install that never wrote SELF_HOSTED to its .env resolved to
false and silently lost multiple accounts per network on upgrade, which
is the opposite of what the documented fallback promises.
* fix: collapse duplicate identities before adding the unique index
Installs predating the index can hold the same identity twice: the
network guard was bypassed for multi-account installs and Pinterest
always created a fresh row. Creating the index on that data aborts
migrate mid-deploy.
Keep the newest row per identity and move its post_platforms over before
dropping the duplicates - the FK is nullOnDelete, so deleting outright
would orphan drafts and scheduled posts.
* fix: refuse a reconnect that authorized a different identity
connectIdentity overwrote platform_user_id with whatever the provider
returned, so reconnecting a card while signed into another account
repointed the row - and every draft and scheduled post bound to it - at
a stranger. LinkedIn guarded this at the controller and Facebook via its
filtered page list; nothing covered X, TikTok, Threads, Discord,
Bluesky, Mastodon, Pinterest, Instagram or Telegram.
Enforce the identity match at the single choke point every connect flow
goes through. Every call site already maps NetworkAlreadyConnectedException
to network_taken, so the refusal surfaces without new plumbing.
Also restore the null-platform guard in the observer: occupiesNetwork
type-hints a non-nullable Platform, so a row without one died with a
TypeError instead of the database's NOT NULL error.
* fix: filter connectable identities on every picker step
YouTubeController::select re-fetched the channels and matched the posted
id straight off the raw list, unlike callback and selectChannel. With a
live youtube_oauth session it let a POST name any channel the Google
account owns and bind it to the reconnect target. It also read the
reconnect from the session while the connect below it read
youtube_oauth.reconnect_id, so the two could disagree - pass the
resolved account through instead.
filterConnectableIdentities also short-circuited in multi-account mode,
and the unique index is scoped to platform rather than network. That let
one Instagram account connect twice, once directly and once via
Facebook, publishing every Instagram post to it twice. The existing
except() already spans networkPlatformValues(), so dropping the
short-circuit closes it.
* refactor: type the connect cards and drop the dead accounts grid
The cards computed inferred account as a required ConnectedAccount and
then pushed undefined onto it (TS2345). CI only runs eslint so it stayed
green, but vue-tsc and editors flag it.
SocialAccountsGrid is referenced nowhere; its reconnect button was
updated in this branch without passing the card id, which would have
been a bug had anything rendered it.
* test: keep the suite on the cloud one-account-per-network default
CI runs the Cloud build, so the suite baseline should be the Cloud
default rather than the self-hosted one. Put phpunit.xml and .env.ci
back to false and make the eight tests that merely need two accounts of
one network as a fixture opt in for themselves.
This also un-deads the config()->set(true) calls the branch had already
added to AuthenticationTest, SyncAccountUsageTest, HasUsageTraitTest and
SocialAccountObserverTest, which the forced true had turned into no-ops.
* fix: connect standalone instagram instead of reopening the picker
The picker emits an already-resolved connect method, but this branch
rewired @select from openOAuthPopup to startConnect. startConnect sends
a bare 'instagram' straight back into its own picker branch, so choosing
"Instagram" closed the dialog and immediately reopened it - the OAuth
window never opened and the standalone flow was unreachable. Only the
via-Facebook button still worked.
Split the URL-opening tail out of startConnect and let the dialog call
that directly.
* fix: reject a telegram reconnect before burning the connect code
The nonce was consumed before connectIdentity ran, so posting /connect in
the wrong chat spent the one-off code and forced the user to generate a
new one. Check the identity first and report wrong_chat instead of
network_taken, which told them to disconnect an account when the real fix
was posting in the channel they were reconnecting.
* fix: leave one target per post when merging duplicate accounts
post_platforms has no unique on (post_id, social_account_id), so a post
holding a row per duplicate account ended up with two enabled rows aimed
at the surviving account and would publish to it twice. Keep one row per
post, preferring a published one so history survives.
* refactor: collapse the repeated connect-flow boilerplate
Four shapes were copy-pasted across the connect controllers:
- the session + permission guard opening 16 actions, now connectWorkspace()
throwing a ConnectPopupException that renders the popup itself
- the reconnected/connected ternary in 13 places, now connectedCallback()
- the "nothing left to connect" branch in 4 places, now
noConnectableIdentities()
- validatedReconnectId() re-querying what reconnectAccount() already does
Facebook, Instagram-via-Facebook and YouTube also re-queried the reconnect
account three or four times per callback; it is resolved once and passed
down. The three GET pickers skipped the manageAccounts check their POST
siblings had, and pick it up from the shared guard.
Drops the color key from connectableOptions and the matching frontend
field - nothing read it. Platform::color() stays; the disconnection
emails use it.
* fix: keep an expired connect popup out of the error log
ConnectPopupException escapes to the framework handler so it can render
itself, which also meant report() ran first: every session_expired and
workspace_not_found popup filed an ERROR and a Nightwatch issue for what
used to be a silent return. A stale popup is a normal outcome, so it now
implements ShouldntReport.
The Mastodon and Threads guards also cleared their provider session
after connectWorkspace(), so a workspace that vanished mid-flow left the
client secret and the OAuth state behind. Clear first, then resolve.
clearMastodonSession() no longer touches social_connect_workspace -
whatever closes the popup already does.
* fix: stop telling users to disconnect an account that is not the problem
Two flows reused popup_callback.network_taken - "This workspace already
has an account for this network. Disconnect it first." - for situations
where that is neither true nor actionable:
- reconnecting a card while signed into a different account on the
provider, now wrong_account
- an empty picker in multi-account mode, where every page or channel on
that login is simply already connected, now all_connected
NetworkAlreadyConnectedException carries the message key so the catch
sites stay one line. handleCallback() also drops its $platform argument;
it read $this->platform for the reconnect lookup and the identity filter
either way, so a caller passing a different platform would have scoped
the lookup to the wrong network.
* refactor: filter linkedin identities with the shared helper
The picker hand-rolled its own reconnect narrowing because the profile
and the pages arrive in two different shapes. Flatten them into one pool
of LinkedIn identities, run the shared filter, and split them again for
the view - the same path Facebook, YouTube and Instagram already take.
Side effect worth having: the picker previously only narrowed on a
reconnect, so it would offer an identity that is already connected and
only fail once the user picked it. It now hides taken identities up
front and says so when nothing is left.
* fix: keep the linkedin picker's own empty state
Routing the picker through the shared filter made every empty pool look
like "nothing left to take", including the pool LinkedIn never filled.
A self-hoster running pages-only who administers no page was told the
network was already connected, or that every account on the login was
taken - both false - and the picker's own "you are not an admin of any
LinkedIn page" state became unreachable.
Only treat it as taken when filtering is what emptied it. Splitting the
pool back also compared the person id loosely on one side and strictly
on the other; one predicate now drives both.
Threads had two forget() calls for a key the top of the action already
clears, and YouTube's picker resolved the reconnect account twice on the
failure path.
* fix: keep the enabled row when collapsing duplicate post targets
SyncPostPlatforms seeds a disabled post_platforms row for every account
in the workspace, so the usual duplicate is one row the user actually
checked next to one they never saw - both pending, both created in the
same second. Ordering only by published-then-newest made that a coin
flip, and PublishPost iterates enabled() only, so half the time a
scheduled post would silently stop reaching that account and take its
caption and per-platform meta with it. This runs once against production
data and the dropped row is gone, so enabled now beats disabled.
Also: the empty-pool exit from the LinkedIn picker was the only one
leaving linkedin_pending - and its tokens - in the session. The
rationale comments move to the docblocks they belong in, and usePage()
comes out of the cards computed.
* fix: stop the migration destroying publish history and automations
Two ways the one-shot merge lost data that cannot be rebuilt:
Surplus published post_platforms rows were deleted. Two duplicate
accounts really could each have published, and each row carries the
platform_post_id for a live post on the network - dropping one leaves
that post unmanageable and invisible to metrics. The docblock claimed
published beat everything; now the code does, and only unpublished
repeats collapse.
Automation nodes persist social_account_id inside a JSON column with no
foreign key, so deleting the loser left RunGenerateNode skipping that
target, or generating nothing at all when it was the node's only
account. The ids are rewritten - current and legacy shapes both - and
entries the merge just turned into duplicates are collapsed.
Ordering is now total (null created_at sorts oldest on every engine,
then id) so a rehearsal on a replica keeps the same rows as the real
run. The LinkedIn picker also passes onboardingProgress inline: it
clears linkedin_pending on the empty path, and a deferred reload would
re-GET the route and swap the empty state for a session-expired popup.
* fix: make the identity merge auditable and stop a second delivery
Self-hosted installs run this unattended and it cannot be undone, so
each collapsed group now logs the workspace, the identity, which row was
kept, which were dropped, and how many post_platforms and automations it
touched. down() says plainly that it drops the index only.
Two narrower fixes:
A post holding a published row plus an enabled unpublished row for the
same account kept both, and PostPlatform::scopeEnabled() filters on
`enabled` alone with no status check - so a republish would deliver the
same content to that identity twice. Once a published row exists, every
unpublished repeat goes.
The automation dedupe ran on every automation in the workspace, not just
the ones the merge rewrote. A node legitimately holding two entries for
one account under different content types would be collapsed to
whichever came first in the array. It now runs only where an id was
actually substituted.
* test: rehearse the identity merge against a messy database
Every test on this migration so far covered a case someone thought to
write, which is why three separate review rounds each found a defect the
earlier ones missed. This builds a deliberately messy database instead -
three workspaces, four networks, one to three copies of each identity,
posts mixing published, pending and failed rows across the duplicates
with enabled flags varying, and automations referencing them in both the
current and legacy JSON shapes - then runs the real migration and
asserts what must be true afterwards rather than what happens to a
particular fixture.
Invariants: no duplicate identity survives, no published row is ever
destroyed, no post ends up enabled twice against one account, nothing in
post_platforms or automations points at a deleted account, and the
newest row of each identity is the one kept.
The generator is seeded, so a failure reproduces, and it asserts its own
output is adversarial - roughly nine duplicate groups and fourteen
published rows - so it cannot quietly degrade into passing on an empty
problem. Verified by mutation: dropping the automation repoint, the
published guard, or the repeated-target collapse each fails exactly the
invariant that covers it.
* fix: stop the youtube picker refetching itself into a cleared session
HandleInertiaRequests defers onboardingProgress for anyone mid-onboarding
- exactly the people connecting their first accounts - so Inertia
re-GETs the picker route right after it mounts. For Facebook and
Instagram that re-entry is harmless and deliberately left deferred, but
YouTube calls the Google API again, and fetchChannels() turns any
failure into an empty list that clears the connect session and swaps the
mounted picker for an error the user cannot retry from. Same guard the
LinkedIn picker already got.
LinkedIn also answered a reconnect that authorized a different identity
with "Page not found", including in the person branch where no page is
involved. Every other platform says wrong_account, which this PR added.
* refactor: drop the unreachable youtube channel picker
Google's own delegation screen already lists every channel on the
account and makes the user pick one before it issues the token, so
channels?mine=true always answers with that single channel and
count($channels) === 1 always won. The picker behind it was never
reached - its Vue page was deleted back in 7c00c338 (January) and
nothing broke, which is the clearest evidence it was dead.
Removes selectChannel(), select(), both routes, the youtube_oauth
session payload and the tests that drove them. If Google ever does
return more than one, the callback connects the first and logs a warning
rather than routing to a screen that no longer exists.
* fix: serialize connects so two popups cannot seat one network twice
The observer's occupiesNetwork() is a check-then-insert with nothing
holding the gap, and the new unique index covers the identity, not the
network. Two tabs finishing OAuth at the same moment for *different*
identities on one network both passed the exists() check and both
inserted, leaving a Cloud workspace with the two accounts the rule
exists to prevent. The same-identity race was already safe - the unique
violation is caught and re-queried.
A database constraint cannot hold this: allow_multiple_social_accounts
is a runtime flag, so the rule is on for Cloud and off for self-hosted,
and an index cannot read config. Lock per workspace and network instead,
the way markAsDisconnected() and ConnectionVerifier already do.
This covers connectIdentity(), which every OAuth flow and the Telegram
action go through. A direct create() still answers to the observer
alone, and a self-hosted install running file cache across several nodes
locks per node.
* fix: handle a busy connect lock on the telegram path
Every other caller funnels LockTimeoutException into its generic
\Exception catch and closes the popup with error_connecting. Telegram
has no such catch, so the new lock could 500 the webhook - and because
the nonce is spent before connectIdentity runs, Telegram's retry of the
same update short-circuits on the consumed code and returns without
dispatching anything. The dialog would spin forever on a code that can
no longer be used.
Also restores coverage the picker removal dropped: the deleted select
tests were the only ones driving a multi-channel response, so nothing
exercised the reconnect narrowing to its own card, or multi-account mode
skipping an already-connected channel. Both are back against the
callback, and removing the narrowing in filterConnectableIdentities
fails them.
* fix: stop the instagram login seating an account already held via facebook
filterConnectableIdentities() drops every identity already connected on the
network, which is what keeps one Instagram account from being seated twice
under its two platforms. Every flow that persists an identity ran it except
the direct Instagram Login callback, so the guard only held in one direction:
InstagramFacebookController refused an account already connected as
`instagram`, but the reverse was allowed through.
With multiple accounts per network enabled the observer's network check is
bypassed and the unique index does not span platforms, so authorizing the
same account through the direct flow created a second row. Both then seed a
post_platform row and the post goes out twice to one account.
* fix: name the real reason when a linkedin profile reconnect switches member
Reconnecting a card narrows the authorized identities to that card's own, so
authorizing a different LinkedIn login empties the pool. selectIdentity()
reported that as "Page not found." for every card, including personal
profiles where no page was ever involved.
A profile reconnect has no page to be missing: an empty pool there can only
mean this login is a different member. Say so with the wrong_account wording
select() already uses for the same condition. Page reconnects keep
page_not_found, where the organization really can be absent from the login.
* fix: surface the busy telegram connect instead of a generic failure
The connect lock timing out dispatches its own 'busy' reason so the dialog
can tell the user to retry, but the dialog only mapped network_taken and
wrong_chat and fell back to error_generic for everything else. The reason
reached the browser and died there, leaving "Could not start the connection"
for a case that just needs another moment.
* test: cover reconnect on every flow that gained it
rememberConnectSession() gave Instagram, TikTok, Threads, Mastodon and
Bluesky a reconnect path they did not have before — TikTok had been actively
clearing social_reconnect_id on connect — and none of them had a test for it.
Facebook, LinkedIn, YouTube, X, Discord, Pinterest and Telegram already did.
Each now covers both halves: authorizing the same identity refreshes the
existing card and reports it as a reconnect, and authorizing a different one
is refused with wrong_account instead of quietly seating a stranger on the
card and every post scheduled against it.
* fix: repair what a reconnect leaves behind when it cannot proceed cleanly
Two things connectIdentity got wrong once the reconnect path existed.
A reconnect through the other variant of a network moves the card to the new
platform — same identity, different API flavor. Post targets carry their own
platform snapshot, and that snapshot picks the publisher, the queue and the
scopes checked before publishing. Left behind, it failed every pending post on
a permission the account no longer needs: an Instagram card moved to the
Facebook variant still demanded instagram_business_content_publish and stopped
with "Missing permissions". Pending targets now follow the card and reset a
content type the new platform cannot publish; published targets keep theirs,
since they record what really went out under a platform_post_id from that API.
The network lock timing out also arrived as a raw LockTimeoutException, which
every OAuth callback filed through its generic catch: an error log and "Error
connecting account" for the exact race the lock exists to absorb. It now
carries a busy messageKey through the branch each flow already handles, the
same way the Telegram path already reported it.
* refactor: resolve the linkedin reconnect card once per select
select() already looked the card up before deciding whether the chosen
identity matches it, then connectPerson() and connectOrganization() looked it
up again on their own — two identical queries per submit, and two places that
could disagree about what is being reconnected. The caller passes what it
already holds.
* test: render the grid's multi-account branch
phpunit.xml forces ALLOW_MULTIPLE_SOCIAL_ACCOUNTS false and no browser test
overrode it, so the card the flag exists to add never rendered anywhere. The
pair pins both sides: a taken network offers no second card when multiples are
off, and offers one when they are on.
* test: pin why the linkedin select guards exist
connectIdentity() already refuses a mismatched reconnect and answers with the
same wrong_account message, so every existing test passes with the two guards
in select() deleted — which is exactly how they would get deleted. What they
actually buy is skipping the avatar download that building the connect payload
runs first.
Both now assert the fetch never happens, so the guards fail loudly instead of
looking redundant.
* fix: carry retrying targets through a variant move, atomically
Two holes in the move added a commit ago.
It only carried pending targets, but a retrying one is not finished either —
the publish job reschedules itself and reads the snapshot fresh on the next
attempt, so leaving it behind meant it retried against the old variant until
it exhausted its budget on a permission the account no longer needs. Failed
and published targets stay put; a publishing one has a job mid-flight already
working from the snapshot it read.
The card and its targets also moved in three separate statements, so a crash
between them left exactly the split this was meant to close. They share a
transaction now.
* chore: drop the dusk selectors nothing reads
Laravel Dusk is not installed — no laravel/dusk requirement, no DuskTestCase,
no browse(). Browser tests run on pest-plugin-browser driving Playwright, and
its @selector resolves to data-testid. The 45 dusk attributes left across 18
components selected nothing.
CLAUDE.md was the reason they kept coming back: it told every agent to add
them. Its browser-testing section now describes the setup that exists —
data-testid targeting, the wait helper these tests need because assertions do
not auto-wait on SPA paint, and why BrowserTestCase keeps Vite real.
Verified before removing: every @selector used in tests/Browser resolves to a
data-testid, seven of them through bound :data-testid, so none depended on a
dusk attribute.
* chore: drop the last one-account-per-network helper
hasConnectedPlatform() has no callers left anywhere — app, tests, views or
routes. It sat directly above getSocialAccount(), which this branch already
removed, and is the same leftover from when a workspace could hold one account
per platform.
---------
Co-authored-by: Paulo Castellano <paulo@castellanos.llc>
Co-authored-by: Cursor <cursoragent@cursor.com>
* Remove the post-templates feature
Removes the browsable post-templates catalog end to end: routes
(app.post-templates.index/apply), controller, form requests, resource,
Registry/PostTemplateData/TemplateNotFoundException services, the
templates:report console command, the templates/ catalog (36 files
across en/es/pt-BR), the templates Index.vue page, the template card
on the post Create.vue choice screen, and its i18n keys across all 16
locales.
Also removes TemplateContextResolver, which fed catalog examples into
PostContentGenerator's AI prompt. The "examples" block (with its
heading) is removed from generator.blade.php rather than left empty;
AI generation no longer references the templates catalog.
The AI content-template system under app/Ai/Templates (image/tweet
card templates for the AI post wizard) and TemplateImageGenerator are
unrelated and untouched.
* Update FormRequest naming examples after the post-templates removal
CLAUDE.md and the Cursor rules cited ApplyPostTemplateRequest and
IndexPostTemplateRequest as naming examples. Both classes were deleted
with the feature, so the guidance pointed at files that no longer exist.
* Share the AiTemplate type between the create screen and the AI wizard
Create.vue and AiPostWizard.vue each declared their own AiTemplate
interface, and they had drifted: the wizard's carried
applies_brand_visuals, the create screen's did not. TypeScript treated
them as unrelated types with the same name, which surfaced as a TS2719
error where the templates prop is passed between them.
The backend sends seven fields (PostController::create), so the shared
declaration follows the payload rather than the union of the two copies.
* feat: add social connect step to welcome before Stripe checkout
Ask new owners to connect a network after referral source so we can track welcome.connect in PostHog and still let them continue to checkout without a connection.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Nest welcome connect copy under a connect array.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Require a connected social account before welcome checkout.
Skip is no longer allowed, and the welcome layout takes a Tailwind size so the connect grid can sit two rows of six.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Harden the welcome connect step after review.
Track connect only after Stripe creates a session, restore a missing workspace before showing networks, and cover the remaining checkout and analytics cases.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Refactor social account status handling across components
Updated the SocialAccountsGrid, NetworkConnectGrid, onboarding, and welcome connect components to utilize the new SocialAccountStatus enum for improved clarity and maintainability. This change replaces string literals for account statuses with the enum values, enhancing type safety and consistency throughout the application.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Refactor workspace resolution in WelcomeController and StoreWelcomeConnectRequest
Updated the WelcomeController and StoreWelcomeConnectRequest to directly access the user's current workspace, simplifying the code by removing the resolveCurrentWorkspace method. This change enhances readability and maintains functionality by ensuring the current workspace is correctly utilized in the connection process. Additionally, removed outdated test cases related to workspace restoration.
* Inline welcome connect PostHog platforms from the current workspace.
Drop the extra helper — the grid already loads accounts the same way as onboarding and accounts.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Inline Stripe checkout into the welcome connect store.
startCheckout was a one-caller wrapper; storeConnect now matches the other welcome steps.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Move welcome connect validation into the controller.
The FormRequest had no input to validate and duplicated step-gating. Require a connected account in storeConnect, and drop the dead owner abort plus the always-true PostHog connected flag.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Show welcome toasts and cover remaining connect cases.
Mount the app Toast host on WelcomeLayout so OAuth, Telegram, and disconnect feedback is visible. Add tests for stale goals, an empty workspace grid, accounts on another workspace, and skipped identify when Stripe fails.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Assume a welcome workspace, validate connect in the FormRequest, and add browser tests.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Rename WelcomeEvent::dashboardFunnel() to funnel().
Co-authored-by: Cursor <cursoragent@cursor.com>
* Identify connected platforms from the social account observer.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Queue connected-platform identify on the posthog queue.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Harden welcome connect: 404 without a workspace, and keep step redirects ahead of connect validation.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Identify connected platforms on workspace and account groups, and keep the account union on the owner.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Share hasCurrentGoals on User and keep Stripe checkout when PostHog capture fails.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Skip welcome connect validation when the controller would redirect the user away.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Move current-goal membership onto the Goal enum.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* Wire onboarding activation into Account, observers, and shared Inertia data
Add onboarding casts/hasFinishedOnboarding, AccessToken ObservedBy,
Platform::connectableOptions, Post/SocialAccount onboarding broadcast hooks,
and lazy onboardingResidual share + SharedData types.
* Register onboarding routes and post-checkout activation redirects.
Wire billing processing and the sidebar checklist so owners land on
activation after subscribe, with locale sidebar/uk onboarding strings.
* Align MCP grant usability with onboarding activation checks
Unbound MCP tokens fall back to the user's current workspace and require
createPost so viewer/unscoped grants neither unlock the checklist nor
broadcast onboarding status.
* Require bound MCP workspace for onboarding activation.
Drop current-workspace fallback from usable MCP grants so checklist
detection and broadcasts match Passport token scoping; viewers still
cannot unlock the MCP step.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Harden onboarding review findings and tighten locale strings.
Fix Welcome/Persona/TrackPost suites broken by the activation route reuse
and PostObserver analytics side effects, restore Echo poll fallbacks,
reject unbound MCP grants in tests, and drop unused onboarding.mcp keys.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Remove unused sidebar and MCP authorization locale keys.
Drop dead sidebar menu/theme strings (including the overwritten
workspace label and api_keys nav entry) and unused MCP authorize
app_title/approving copy across all locales.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix SetLocale crashing on Passport Symfony OAuth responses.
OAuth errors return a raw Symfony Response without withCookie(); attach
the default locale cookie via headers so authorize no longer 500s.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Prompt OAuth guests to log in before rejecting unknown clients.
MCP Inspector often reuses a stale client_id; validateAuthorizationRequest
was returning invalid_client JSON before the login redirect. Guests now
hit /login first, then client validation runs after authentication.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Render Inertia OAuth authorize errors for browser logins.
After login, Inertia follows the intended authorize URL; raw invalid_client
JSON broke that visit. HTML/Inertia requests now get mcp/AuthorizeError
while API JSON clients still receive the OAuth error payload.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Detect Inertia OAuth error pages via Request::inertia().
Use the framework helper so post-login authorize failures keep returning
an Inertia page instead of raw OAuth JSON.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify OAuth authorize error page detection to expectsJson.
Drop the X-Inertia header sniff; browser and Inertia visits already do
not expectsJson, while API clients still receive the OAuth JSON payload.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Share MCP authorize layout and drop the error close button.
Keep authorize and authorize-error on the same centered card shell instead of the auth split layout.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify onboarding activation for reviewability and safety.
Use an exists-based MCP check, keep GETs read-only, move sync into
syncAndNotify, clear MCP skips on connect, restrict complete to owners,
and share Echo/poll via one composable.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Move MCP OAuth authorize UX out of the onboarding PR.
Keep the activation checklist focused; OAuth guest/error-page work now
lives on fix/mcp-oauth-authorize-ux.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix corrupted French MCP locale after OAuth key cleanup.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Restore MCP OAuth authorize UX onto the onboarding branch.
Keep authorize error page, guest login-before-client validation, and
SetLocale Symfony cookie fix in #250.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix OAuth prompt=none redirects and harden onboarding tests.
Keep login_required/consent_required as redirects instead of Inertia,
add regression coverage for owner-only activation, require invite email
confirmation, and align MCP connected apps with the sessions list UI.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify onboarding guards and dedupe viewed analytics.
Introduce isOnboardingOpen / belongsToAccount helpers, collapse
duplicated sync/dispatch paths, and capture onboarding.viewed once
per account.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify onboarding event, observers, and status helpers.
Tighten Account onboarding predicates, drop nullable broadcast/dispatch
APIs, and collapse repeated observer/controller guards.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Treat in-app users as always having an account.
Add resolveAccount(), tighten belongsToAccount to string ids, and fold
guest residual handling into ResolveOnboardingStatus.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Rename onboarding residual share test to progress.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify onboarding status and rename residual to progress.
Use accountOrFail, extract MCP onboarding scope, auto-leave the ready
screen, and send non-onboarding checkout back to accounts.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Extract HasAccount and prefer data_get in onboarding flows.
Move account helpers off User, drop nullable sidebarProgress, and
read OAuth/onboarding payloads with data_get.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify onboarding checks and extract HasOnboarding.
Use Eloquent + policies for MCP/backfill paths, and move account
onboarding helpers into a dedicated trait.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add trait tests and tidy onboarding imports.
Cover HasAccount and HasOnboarding under Models/Traits, prefer filled() for checkout session ids, and import Throwable instead of FQCN.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify checkout session_id and OAuth error props.
Read session_id via request->string(), and take OAuth error details from the League exception instead of decoding the response body.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify PostObserver onboarding notify path.
Share one otherPosts check for first-create and last-delete instead of separate callbacks.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Use post author as onboarding sync actor.
Drop Auth::user() preference in PostObserver; checklist sync attributes to $post->user.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify SocialAccountObserver and OAuth authorize flow.
Share create/delete onboarding notify, drop Auth actor fallback to owner, and inline Passport Inertia error handling.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Use lazy Inertia props for onboarding partial reloads.
Drop partial-header branching; wrap page props in closures and always redirect completed/dismissed accounts to the calendar.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Defer sidebar onboarding progress and stamp completion as owner-only.
Skip the MCP checklist work on full Inertia visits via deferred shared props,
early-exit token scans, and keep account completion stamps owner-gated.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify deferred onboarding progress share via canShowProgress.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add User firstName for shared auth and simplify onboarding page.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Move User firstName coverage into UserTest.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Use first_name directly without empty-name fallbacks.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Resolve onboarding sample prompt on the frontend via i18n.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Stamp onboarding completion via the account owner after teammate unlocks.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Count only the account owner MCP grant toward onboarding activation.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix OAuth consent auth-token mismatch for mid-activation owners.
Skip deferred onboardingProgress on Passport authorize so Inertia does not
rotate the session authToken, cover happy and stale-token paths in tests,
and polish MCP setup copy plus sidebar/onboarding layout.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Keep users on onboarding after activation completes.
Stamp completion and re-render the finished checklist instead of
redirecting to the calendar so owners can review the done state.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Clarify Passport consent-view opt-out and guard app-route deferral.
Rename the authorize-only route check and assert onboardingProgress still
defers on calendar, onboarding, and MCP settings.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Harden onboarding completion and MCP consent workspace binding.
Reject OAuth approve without a workspace, retry auto-complete until
stamped, send dismissed complete straight to calendar, and cover the
device consent defer opt-out.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Enable activation checklist for self-hosted installs.
Remove the self-hosted onboarding redirects, keep the SaaS-only dismiss backfill, and cover subscription-less owners plus skip/complete destinations.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add GitHub, Hacker News, and directories referral sources.
Expand the welcome referral step with open-source and directory discovery channels.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Refine welcome referral sources and labels.
Split Instagram/Threads, add Founder, and shorten Google, GitHub, AI, and blog option labels.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Sort accounts platforms alphabetically and drop connect hover plus.
Reuse connectableOptions for the accounts index and remove the unused plus badge on disconnected cards.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Centralize PostHog once-capture so disabled installs don't burn dedupe keys.
Move isEnabled + Cache::add into PostHogService::captureOnce and route onboarding viewed/step events through it.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify onboarding backfill to complete every existing open account.
Drop self-hosted and subscription filters; down clears completed_at again.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop PostHog captureOnce and use plain capture for onboarding.
Remove cache-based event dedupe; callers rely on PostHogService::capture gating.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* Rename pre-subscription onboarding funnel to Welcome.
Move the ICP steps to /welcome, drop the social-connect checkout gate, hold unpaid members on a subscription-required screen, and keep legacy /onboarding URLs working until the post-subscription checklist lands.
Closes#237
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop legacy /onboarding ICP URL aliases.
Unfinished users re-enter Welcome via EnsureAccountReady on next login; /onboarding stays free for the post-subscription checklist.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify Welcome PostHog event names.
Use welcome.persona/goals/referral and drop the unused checkout case — begin checkout stays on the frontend as checkout.started / begin_checkout.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Slim welcome goal options to match the #204 set.
Drop team_collaboration, automate_api, and track_performance so the goals step stays at nine choices.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Split Welcome AI goals into TryPost AI and MCP assistants.
Rewrite ai_content for in-app generation and add use_mcp so Claude/ChatGPT/Cursor intent is captured separately across locales.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Harden Welcome goals gate and drop dead checkout UI.
Treat removed goal values as incomplete so mid-funnel users re-select, remove the unused canCheckout branch, and fix the pt-BR welcome progress label.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add password visibility toggle to the login form.
Match the register eye control so users can reveal their password while signing in.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Point the sidebar community link to Discord.
Replace the X stay-updated entry with Join Discord and the trypost.it/discord invite.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Rename the sidebar Discord link to Discord community.
Softer label that matches the other support nav items.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix broken Turkish Discord community translation.
An unescaped apostrophe left a parse error in lang/tr/sidebar.php.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add workspace MCP settings and token access controls.
Ship MCP settings UI, OAuth revoke/list helpers, Passport deploy wiring,
and workspace.token:mcp gating so assistants can connect without pulling
in welcome/onboarding from the parent epic.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Type MCP client config shapes instead of string checks.
Encode http/config-root on each advanced client and tighten primary
client ids so snippet generation does not branch on magic strings.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Polish MCP settings follow-ups from review.
Translate Ukrainian MCP copy, deep-link ChatGPT into connector
creation, drop an unused asset and revoke arg, and assert PATs are
rejected on the MCP endpoint.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Harden MCP connected clients, revoke scope, and OAuth consent.
List recoverable sessions with live refresh tokens, revoke only PATs,
throttle registration alone, and block viewers from authorizing MCP.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify MCP OAuth route throttling to a single middleware group.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Allow workspace viewers read-only MCP access with web policy writes.
Mirror the web app: MCP connects on view + OAuth mcp:use, write tools
enforce createPost/update/delete/manageAccounts/manageTeam, and demotion
to Viewer keeps grants. Cover role denials, consent, and disconnect.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Harden MCP tool authz with shared workspace helpers.
Route ApiKey tools through AuthorizesMcpTool, fail closed on null user
or policy argument, and resolve the current workspace before mutating.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop redundant string casts on validated request data.
Enum::from and validated() fields are already strings, so the casts
add noise without changing behavior.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Show only the current user's MCP connections in settings.
Match API keys privacy: list and disconnect your own OAuth clients,
not teammates' across the account.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Cover LoadWorkspaceFromToken gaps and harden AuthorizesMcpTool tests.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop redundant is_string guard before UpdatePostTool find.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Refactor AppSidebar to always show MCP link and simplify route middleware definition in ai.php. The MCP link is now consistently displayed regardless of the current workspace state, and the route middleware syntax has been streamlined.
* Refresh MCP connected clients with Inertia usePoll.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Bump laravel/mcp to 0.9.1 and add the TryPost server icon.
Requires laravel/boost 2.5 for the Icon attribute; expose images/trypost/icon.png on TryPostServer.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop no-op ReflectionClass import in TryPostServerTest.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Adds a single-select referral-source step between the goals and connect
steps of onboarding. The choice is stored on users.referral_source and
mirrored to PostHog, mirroring the existing persona and goals steps.
- ReferralSource enum (12 sources) + nullable users.referral_source column
- referralSource()/storeReferralSource() controller actions with the same
self-hosted, subscribed, persona and goals guards as the sibling steps
- connect() now requires a referral source before rendering
- Single-select ReferralSource.vue page mirroring the goals step
- Localized across all 15 locales
Bluesky does not hydrate link cards server-side, so build the app.bsky.embed.external embed at publish time: detect the first URL, scrape its OpenGraph metadata, and re-upload the og:image as the card thumb. Works for web, API and MCP. Adds a posts/link-preview endpoint so the editor renders the card live. The thumb download is SSRF-guarded and does not follow redirects.
After picking who they are, users now pick what they want to achieve with
TryPost. A multi-select goal step (12 options + an exclusive "just exploring"
and "something else") sits between the persona step and connect, mirroring the
persona screen's style.
The goals persist to a json column on users and are mirrored to PostHog on
identify (onboarding_goals array plus a boolean per goal), so campaigns can be
cross-tabbed against the intent they actually attracted. connect now requires
both a persona and at least one goal; persona store advances to the goal step.
Options are grounded in TryPost's real capabilities (publishing, AI content,
brand voice, automation via API/MCP, collaboration, analytics) and copy is
localized in en/es/pt-BR.
- publishCarousel now caps images at the platform max so the API/MCP paths
can't bypass the 10-image limit the UI enforces
- waitForProcessing throws on timeout instead of posting against an asset
still being processed; poll attempts/interval extracted to overridable
protected methods for testability
- scope EnsureHasWorkspace to connect entry points + disconnect only;
session-driven OAuth callbacks/selects stay ungated so a missing current
workspace can't HTML-redirect the popup instead of closing it cleanly
EnsureAccountReady bundled a subscription gate (redirects to onboarding,
SaaS only) with a workspace gate (redirects to workspace creation). The
connect routes can't sit behind it because connecting/disconnecting
happens during onboarding, before a subscription exists.
Split the workspace gate into a standalone EnsureHasWorkspace middleware:
- EnsureAccountReady is now subscription-only.
- EnsureHasWorkspace redirects to workspace creation when there is no
current workspace, in both SaaS and self-hosted modes.
- The social connect group gains EnsureHasWorkspace; the main app group
gains it alongside EnsureAccountReady (listed after it, so the
subscription gate still runs first — no custom middleware priority).
- The repeated `if (! $workspace) redirect()` guard is removed from the
connect/store/authorize/disconnect/index/toggle handlers, and their
return types are tightened (no more dangling RedirectResponse).
LinkedIn connect's no-workspace path changes from a popup callback to the
same redirect as the other platforms.
Collapse LinkedIn to one content type per account kind (linkedin_post, linkedin_page_post). Publishers infer the publish format from the attached media — text, single image/video, multi-image carousel, or PDF document — matching how facebook_post/x_post already work; PDF is exclusive of any other attachment. Removes the editor variant picker, keeping only the PDF document title field. Includes a data migration collapsing the retired carousel/document content types.
Replace the two LinkedIn account cards with a single Connect LinkedIn button: one unified OAuth grant (linkedin-openid driver, union of scopes) then a post-callback identity picker to post as the personal profile (linkedin) or a company page the member administers (linkedin-page). The chosen organization is validated against the admin-verified list from the OAuth grant. Per-capability gating via LINKEDIN_ENABLED / LINKEDIN_PAGE_ENABLED supports profile-only or org-only self-hosting. Removes LinkedInPageController, LinkedInTokenSynchronizer, the standalone linkedin-page connect routes, and the unused redirect_page config.
Add a connect step between persona selection and Stripe checkout: persona ->
connect >=1 network (server-enforced) -> checkout. Extract the network grid into
a shared NetworkConnectGrid used by both onboarding and the accounts page, which
is redesigned from a table into the same grid (one account per network, with
per-card connect / connected+disconnect / reconnect states). Drop the openDialog
auto-open flow now that networks are shown inline.
Bug fix
- Surface the localized "network already connected" message on the
Facebook/Instagram/InstagramFacebook/LinkedInPage/Threads/YouTube OAuth
callbacks: catch NetworkAlreadyConnectedException before the generic catch
so the conflict no longer falls through to a generic error + Log::error.
Scope / dead code
- Remove the orphaned BillingController::checkout() + app.billing.checkout route
(onboarding starts checkout directly); delete the now-dead DiscordWidget and
useFeatureAccess composable; prune orphaned i18n keys left by removing the
plan picker / upgrade dialog / count limits (billing.subscribe.*,
accounts.limit_reached, workspaces.limit_reached, common.discord.*).
- Drop the unused `plan` prop from the usage page and the unused `label` from
the onboarding persona payload (labels come from i18n); remove
Persona::options()/label().
Conventions
- declare(strict_types=1) on the two new migrations.
- Extract autofill validation into AutofillBrandRequest (FormRequest).
- Drop the unused $plan param from AccountPolicy::swapPlan.
- CreateUser: drop the stale config('cashier.trial_days', 7) fallback (now 8).
- Rename LimitEnforcementTest to InvitePermissionTest; use Pest mock() helper in
WorkspaceQuantitySyncTest; move shared test helpers into Pest.php.
- Memoize BillingCycle window() + subscription lookup.
Pricing
- Bill per workspace ($12/mo or $120/yr each); Stripe quantity tracks the
workspace count and syncs on workspace create/delete.
- 2,500 AI credits per workspace, pooled at the account level; monthly reset
on the billing anniversary, annual granted upfront (no rollover).
- One social account per network per workspace; remove all count-based limits
(workspace/social/member) and the legacy plan tiers (single Workspace plan).
Onboarding (cloud only: SELF_HOSTED=false + PostHog)
- Replace the /subscribe plan picker with /onboarding persona selection
(Creator/Freelancer/Startup/Agency/Small business/Other), saved on the user
(users.persona) and mirrored to PostHog, then Stripe Checkout on the monthly
price. 8-day trial so Stripe displays 7.
Billing screen
- Remove the Change Plan dialog (dead with a single plan); add an annual-upgrade
banner for monthly subscribers (swapToYearly).
- Current-plan card shows the workspace count instead of the plan name.
System AI
- Brand analyzer / workspace autofill is always allowed and never debits credits
(system feature, not the user's usage).
Self-hosted (SELF_HOSTED=true) bypasses all billing, credit, limit, network,
and onboarding logic.
Connect a Discord server via OAuth (bot authorization) and schedule/publish
messages to its channels, with mentions and rich embeds.
- Connect: custom Socialite Discord provider (bot scope) maps the authorized
guild to a SocialAccount; throws if no server was authorized.
- Publish: DiscordPublisher posts via the global bot token, validates the chosen
channel belongs to the connected guild (anti cross-guild), optimizes media,
builds allowed_mentions only from explicit mention chips (no accidental pings),
and renders rich embeds.
- Compose: per-post channel picker (live lookup), mention autocomplete and an
embed editor, gated by a required-channel compliance rule; Discord post preview.
- Enum/config/content-type wiring, ConnectionVerifier health check, throttled
lookup endpoints, i18n (en/es/pt-BR), and tests.
Operators must create a Discord application and set DISCORD_CLIENT_ID,
DISCORD_CLIENT_SECRET, DISCORD_BOT_TOKEN and DISCORD_CLIENT_REDIRECT.
Replace the RSS-2.0-only SimpleXML parser with SimplePie so the Fetch RSS
node reads Atom 1.0 (YouTube, GitHub, The Verge…) and RSS 2.0 + namespace
extensions (dc:, content:, media:, yt:, itunes:). Each item exposes stable
cross-format aliases (title, link, date, content, author, …) plus every
namespaced field flattened for use as {{ fetched.* }}.
Add a feed-inspection endpoint that discovers a feed's real fields and feeds
them into the editor's expression autocomplete. Allow {{ }} expressions in the
feed URL via a ResolvableUrl rule. Parsing moves to a dedicated FeedParser
service; the fetch keeps the SSRF guard and gains XXE-safe parsing.
Connect a channel by issuing a one-time code the user posts as /connect <code>
in their channel. A secret-token-guarded webhook matches the code, links the
channel as a SocialAccount (chat_id in meta), and records it on the request so
the connect endpoint can poll for completion. Adds the TelegramConnectRequest
model + migration, the connect/status endpoints, the public webhook route (CSRF
exempt), a ConnectionVerifier branch (getChat liveness), and a telegram:set-webhook
command. Tests cover the code issue, webhook link, secret rejection, expired/
unknown codes, status polling, and the command.
Split the automation detail screen into four route-based tabs behind a
shared AutomationHeader:
- Workflow: the existing editor canvas.
- Invocations: a paginated, filterable run log with expandable per-node
detail, a refresh control, and a loading state.
- Metrics: KPI cards, a runs-over-time @unovis chart with locale-aware
date labels, and a posts-by-platform breakdown over a date range.
- Settings: rename, an activate/pause switch, and a danger-zone delete.
Invocations and Metrics report only real executions via a new
productionRuns scope, so manual test runs (dry or with real data) never
leak into the log or the charts. The now-unused excludingDryRuns scope
is removed.
Generated copy now flows the most-restrictive platform context through
the humanizer too, and the editor guide documents every available
expression grouped by source node.
- 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.
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>
Core changes:
- Replace Unsplash slide pipeline with gpt-image-2 via Laravel AI SDK.
New AiImageClient builds prompts from a Blade template seeded by the
workspace's ImageStyle enum, content language, brand color (mapped to a
human-readable name via HexColorName helper) and brand description.
- Drop Template B from TemplateImageGenerator: every slide now renders as
Template A (full-bleed photo + bottom gradient + white/grey overlay).
Removes renderTemplateB, roundCorners, blendHex, ensureContrast and the
closing-slide pipeline.
- StreamPostCreation creates the Post directly and dispatches
PostCreationReady with post_id; the wizard kills its preview step and
redirects straight to the post editor on completion. Finalize endpoint
removed.
- New Workspace.image_style enum field with an 8-option visual picker
shared by /workspaces/create and /settings/workspace/brand via a single
BrandForm component (autofill is a prop). 8 sample webp thumbs ship
under public/images/branding/image-styles/.
- Media items gain optional source ('ai'|'unsplash'|'giphy') and
source_meta (recipe needed to regenerate AI images later); the gallery
picker tags Unsplash/Giphy attachments.
- Brand-color autofill: new CssColorFrequencyExtractor parses every
hex/rgb/hsl value in the homepage CSS, clusters perceptually similar
shades in CIE LAB (Delta E 76 < 12), filters neutrals and returns the
most frequent cluster. Solves Tailwind/utility-CSS sites where no
semantic --primary variable is exposed.
- Credits: gpt-image-2 metered at 15 credits/image (low quality default).
- Layout: AuthSplitLayout right column is sticky/h-svh so the form
textarea growth no longer stretches the marketing slider.
- i18n cleanup: localized labels follow the no-em-dash convention.
Drops the dedicated /settings/authentication/providers/{provider}/callback
route added in the previous refactor — registering a second callback URL
in each OAuth app is more ops cost than the trade is worth.
Back to one callback URL per provider, with a small `Auth::check()`
branch in the auth controllers' callbacks. The check is safe because
the redirects that initiate the round-trip enforce the right
middleware (signup/login is `guest`-only, connect is `auth`-only),
so the auth state at callback time matches the flow's intent.
Splits the OAuth connect flow off entirely from signup/login so each
route has a single responsibility.
- routes/auth.php returns to its original state — redirect + callback
both back inside the `guest` group.
- routes/app.php gains a paired callback route at
/settings/authentication/providers/{provider}/callback.
- AuthenticationController::connectProvider /
connectProviderCallback override Socialite's redirectUrl so the
round-trip stays on the connect-flow URL. The Auth::check() branch
in the auth controllers is gone.
The OAuth apps in Google Cloud and GitHub Developer Settings need the
new callback URL registered alongside the existing one — documented in
.env.example.
The Connect button on /settings/authentication pointed at the
auth.{provider}.redirect routes that live behind `guest` middleware,
so authenticated users were bounced to /app/home before reaching
Socialite. The OAuth callback also needed to handle two flows
(signup/login vs link to current user) but had no branch for the
second case — meaning a different-email GitHub account would have
been registered as a new user, logging the original session out.
Splits the flows by intent:
- New `app.authentication.connect-provider` route in the auth group,
handled by the settings controller (where it sits next to
disconnect-provider). Replaces the OAuth signup link as the
Connect button's target.
- Auth callbacks moved out of the guest group (still one URL per
provider, since OAuth apps only register one) and gain a single
Auth::check() branch that calls connectToCurrentUser().
- connectToCurrentUser() rejects if the provider id already belongs
to a different user; otherwise sets it on the current user and
redirects back to settings with a flash message.
Mentions in post comments
- @mention autocomplete (workspace members, current user excluded) with
marker syntax @[uuid] persisted, display names rendered via CommentBody
chips; live edit replaces markers with names and converts back on save.
- NotifyMentions action with workspace-scoped membership check, dedupes
same user, only newly-added mentions on update.
- Email + in-app via SendNotification job, respecting per-user
notification_preferences.mentioned_in_comment.
- Heartbeat-based presence (Cache, 60s TTL, 30s ping) so online recipients
get only the in-app notification — no email noise.
- Real-time bell on workspace.{id}.user.{id} private channel
(NotificationCreated event), scoped channel name avoids client-side
filtering and lays out a convention for future workspace channels.
- Mailable localized via lang/{en,es,pt-BR}/mail.php; Maizzle source
template for the email is committed and built into resources/views/mail.
AI generation refactor (Action layer + MCP)
- Extracted Actions/Ai/Generate{Image,Video} with QuotaExhaustedException
so agent tools and MCP tools share a single domain entry point.
- Mcp/Tools/Ai/Generate{Image,Video}Tool registered in TryPostServer; both
return MediaResource payloads.
- Orientation::imageApiSize maps non-OpenAI ratios to 1:1/2:3/3:2.
- config/ai.php is now the single source of truth driven by env, removing
the trypost.ai shim. Default text/image providers flipped to OpenAI.
Settings/UX
- /settings/workspace split into shadcn Tabs (Workspace / Brand / Users)
with three components.
- /assets and the in-editor MediaPicker open the ImagePreviewDialog
lightbox on image click while preserving action button behaviour.
- Comments tab landed via ?tab=comments&comment=<id> from notification
click (scroll-to + temporary highlight).
- Mention autocomplete popover flips above when near the viewport bottom.
- Real social platform PNGs replace Tabler brand glyphs in schedule
pills and post list, with hover tooltip carrying display_name + handle.
Bug fixes
- AcceptInvite: controller now passes workspace + role payload that the
Vue page expects; login/register CTAs preselect the invite email.
- WorkspaceInvite mailable: stopped referencing nonexistent
$invite->workspace and $invite->role; column added to the migration,
Invite model casts role to WorkspaceRole, CreateInvite persists it.
- PostCommentCreated: added broadcastAs so .PostCommentCreated actually
matches the Echo listener; payload now includes mentioned_users so
receivers render the chip correctly without a refetch.
- Preview components for X/Pinterest/Threads/Bluesky/LinkedIn/Mastodon/
TikTok/YouTube switched from item.type === 'image' to
!isVideoMedia(item) so media without a persisted type still renders.
- UpdatePostRequest now accepts media.*.{type,mime_type,size,...} so the
posts.media JSON keeps the metadata that the previews need.
- Removed throttle:6,1 from social connect routes (was 429ing legitimate
OAuth retries).
- Used MediaType enum cases instead of literal 'image'/'video' strings
when creating media rows.
Tests
- MentionParser unit tests, NotifyMentions feature tests including
online/offline channel selection and preference gating, MCP AI tool
happy paths, MentionedInComment mailable rendering, AcceptInvite +
search-members + index mentioned_users path. 1229 passing.
- gallery: extract /assets tabs (uploads, Unsplash, Giphy) into shared
GalleryBrowser used by both /assets and a new MediaPickerDialog inside the
post editor; add JSON search endpoint for workspace assets with tests
- emoji: replace broken emoji-picker-element web component with a custom
EmojiPicker (full Unicode set, search, categories, recently-used,
light/dark, i18n)
- preview tab: platform selector pills, variant tabs (data-driven from
content_types map) so the user can switch Feed/Reel/Story etc. and have
it autosave through the same handler ScheduleTab uses
- platform logos: shared usePlatformLogo composable (logo + label + content
types); replaces inline maps across 5 components, fixes
instagram-facebook falling back to default.png
- tooltips: hover details (display_name · @username + platform label) on
platform avatars across editor, posts list and calendar
- settings cards: show ` · @username` in the title bar so multiple accounts
on the same network are distinguishable
- routes: drop the throttle:6,1 group middleware on social connect routes
(was 429ing legitimate OAuth retries) and rely on the default limiter
Users on the Brand step can type their website URL and click
'Preencher' (Portuguese) / 'Autofill' (English). The backend fetches
their homepage, parses standard meta tags, and returns:
- name ← og:site_name | title (suffix stripped at ' | ', ' - ')
- description ← meta[name=description] | og:description
- language ← <html lang> mapped to en / pt-BR / es
- logo ← apple-touch-icon | largest link[rel*=icon] | og:image
The logo is downloaded, validated (mime whitelist, 2MB max), and
attached to the workspace's 'logo' media collection so the avatar
updates immediately.
Zero LLM calls, zero external APIs. Uses symfony/dom-crawler +
symfony/css-selector (newly required) for meta extraction. Everything
else (Http client, workspace media, Intervention) was already in the
project.
Security:
- SSRF guardrail: resolves the host, rejects private / loopback /
link-local ranges, enforces http(s) scheme on both the initial
page fetch and the logo download.
- Rate limited at 10 req/min per user via the throttle middleware
alias on the route.
- Logo content-type must be one of the allowed image mimes; wrong
types are silently dropped so users never see broken images.
UX:
- 'Autofill' button next to the website input, disabled until there
is a URL; shows a spinner while running.
- If a logo was captured, a small preview appears below the input
so users can see what was pulled before saving.
- Success and error paths both surface as vue-sonner toasts, with
translations in en / pt-BR / es.
- Failures leave the form untouched — nothing is destructively
overwritten if parsing gave us nothing.
Tests (16 new): action-level coverage for happy path, title-suffix
fallback, language code normalization across 6 locales, scheme
rejection, private-range SSRF rejection, implicit https prefixing,
empty sites, upstream errors, and wrong-mime logo rejection. Plus
two controller-level tests for the autofill endpoint.
After users pick their persona (role), they now land on a new Brand
step that collects the same fields available in Settings → Workspace
→ Brand: website, description, tone, voice notes, and content
language. When they continue, every AI-generated post for this
workspace already has sensible defaults — before the user's first
post is ever drafted.
Flow:
Role (persona) → Brand (new) → Connections → Subscription → Completed.
A 'Skip for now' button on the brand step advances to Connections
without touching the workspace (defaults stay at their seed values).
Backend:
- Setup enum gets a new Brand case slotted between Role and
Connections with matching stepNumber updates.
- OnboardingController::brand() renders the form pre-filled from the
current workspace. storeBrand() validates via a new
StoreBrandRequest form request and writes the fields onto the
workspace, then advances setup. skipBrand() just advances.
- storeRole() redirects to brand instead of account. enforceStep()
knows how to redirect users whose setup is Brand.
- Three new routes: GET /onboarding/brand, POST /onboarding/brand,
POST /onboarding/brand/skip.
Frontend:
- New Brand.vue page mirrors the Settings brand form but inside the
onboarding AuthLayout. Tone + language sit side by side, both
selects take full width. Translations added to en, pt-BR, and es.
- Wayfinder regenerated so the page can import storeBrand / skipBrand.
Tests:
- Renamed 'redirects to step2' → 'redirects to brand step' and
assert new setup.
- Added six new tests covering brand step auth, redirects, render,
successful store, validation of tone and content_language, and
skip.
- Updated UserSetupTest for the new enum case + reshuffled step
numbers.
- Sidebar reorganized: Workspace group (connections, hashtags, labels,
API keys, settings) and Account group (settings, usage, billing)
- Account group only visible to owner and hidden in self-hosted mode
- Onboarding simplified: role -> account (connect socials) -> completed
-> redirect to /subscribe. Removed Subscription setup step.
- Subscribe page redesigned with 4 plan cards, monthly/yearly toggle,
trial info, and per-plan features list
- Billing page redesigned following Sendkit layout (sections with
sidebar labels)
- Processing page uses usePoll with immediate watch for subscription
activation
- Cancel URL redirects directly to /subscribe
- Account settings page with name and billing_email (syncs with Stripe)
- Usage page with ring meters for all plan limits
- Settings layout tabs only for user pages (profile, password,
notifications). Workspace/API keys/billing are standalone pages.
- GoogleAuthButton extracted as reusable component
- WorkspaceRole TypeScript enum for type-safe role checks in frontend
- Trial period changed to 7 days
- Fixed onboarding loop when user confirms email
- All 1101 tests passing
- 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