* Fall back to display name when a published-post email has no username.
Facebook Pages often have no vanity username, which left the email as "Facebook Page (@)". Use username, then display name, and omit the empty parentheses when both are missing.
* Move the notification account label onto PostPlatform.
The fallback belongs on the row that always has a platform, so mail and in-app notify can call one method instead of guarding a missing social account at every call site.
* Cover the in-app published/failed notification body for Facebook Pages.
The email tests were not enough — SendNotification body is what the in-app inbox shows, and that path also goes through notificationLabel().
* Give the foreign key a backing index before dropping the unique
social_accounts.workspace_id carries a foreign key, and the composite
unique index is the only one covering it, as its leftmost prefix. MySQL
refuses to drop the sole index backing a foreign key (SQLSTATE[HY000]
1553), so both rehearsal suites failed in beforeEach and never ran a
single assertion on MySQL. Add a plain index on workspace_id first;
PostgreSQL has no such requirement and simply carries it.
This unmasks one assertion underneath that had never executed: the
automation graph comparison at DuplicateIdentityMigrationTest.php:419
depended on JSON object key order, which MySQL normalises on storage.
(cherry picked from commit 98a494bd2205e873321a18232f63b358ae259fdf)
* Compare JSON payloads without depending on key order
MySQL normalises JSON object keys (length, then lexicographic) on
storage, so an identity comparison against a literal asserts how the
driver chose to lay the object out rather than what it contains.
PostgreSQL preserves insertion order, which is why these passed there.
toEqual compares associative arrays recursively without regard to key
order. Applied to every assertion in this class, including the few that
pass today only because their keys already happen to match MySQL's
ordering.
(cherry picked from commit 3124023c548d6c2b8b52126afc6fc5f38d461ea6)
* Match logged SQL without depending on identifier quoting
Four DB::listen predicates matched 'select * from "post_platforms"'.
PostgreSQL quotes identifiers with double quotes and MySQL with
backticks, so on MySQL the predicates never matched, the simulated
mid-run pause never fired, and the race these tests exist to cover went
unexercised while the tests still reported failures elsewhere.
Compare against the unquoted form via a small helper.
(cherry picked from commit 67a81df5de155e80227df748b34cd8b3cfd744f9)
* Cast raw boolean reads in tests so they pass on MySQL
Three assertions read oauth_refresh_tokens.revoked through the query
builder rather than Eloquent, so no cast applies and the driver's native
representation leaks into the test: a real boolean on PostgreSQL, 1 on
MySQL. Cast explicitly at the call site.
(cherry picked from commit 2911c5c48cf65d24a34a41e667335c40005839a7)
* Use a scheduling date inside MySQL's TIMESTAMP range
MySQL TIMESTAMP columns end at 2038-01-19, so the 2099 sentinel these
tests used is rejected outright with SQLSTATE[22007]. 2037-12-31 still
reads as a far-future schedule and works on both engines.
(cherry picked from commit bde33eb239cdbd3a5567d4c21e1d85302913cdd7)
* Remove the duplicate-identity migration scenario test
The suite rebuilt a pre-migration schema by dropping the unique index in
beforeEach and re-running the migration by hand, exercising a database
state the application never runs in.
* Fix the MySQL rollback path and run CI on both engines
The migration's down() dropped a unique whose leftmost prefix is an FK
column, which MySQL refuses when nothing else backs the constraint
(SQLSTATE 1553). It now creates a standalone index first, so
migrate:rollback works on MySQL and stays a no-op change for PostgreSQL.
up() is untouched: every database already migrated keeps its schema.
The rehearsal test calls that down() instead of hand-rolling the drop,
so it exercises the real rollback rather than an imitation of it.
Matches logged SQL through the connection's query grammar rather than
stripping quote characters, and adds a MySQL leg to the backend CI job.
* Use a readiness check both database images can run
mysql:8.4 installs mysql-community-server-minimal, which ships neither
mysqladmin nor the mysql client, so a mysqladmin health command never
succeeds and the service never reports healthy. Both images run their
init phase without networking, so an open port is the point either
engine starts accepting connections - one check covers both, and the
per-engine matrix key goes away.
* Use each engine's own readiness tool
pg_isready and mysqladmin ping are what the respective images ship for
this, and the mysql image's entrypoint invokes mysqladmin itself, so it
is present. Keeps 20 retries, which MySQL needs to finish initialising.
* State the two-engine ceiling as a rule, not a test detail
The 2038 TIMESTAMP limit binds anything written to the column, not just
the sentinel dates in fixtures, and the same reasoning generalises: what
the app supports is the intersection of both engines.
* Let the release image connect to MySQL
The published image installed only pdo_pgsql, so DB_CONNECTION=mysql
failed with "could not find driver" before any query ran - the app
supports MySQL but the image people actually deploy could not reach it.
mysql-client mirrors the postgresql-client already present, for
artisan db and dumps.
* Keep "backend" a single required status check
Matrixing the job split its check in two, so the "backend" context the
branch protection requires was never reported and every PR sat waiting
on it. The matrix is now "tests" and a small "backend" job gates on it,
which keeps the required check stable however many engines the matrix
grows to - and leaves the open PRs mergeable without a rebase.
---------
Co-authored-by: Paulo Castellano <paulo@castellanos.llc>
* 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>
* Stop paying X for token checks the refresh already proves
RefreshSocialToken verified rotating-refresh platforms instead of
refreshing them. On a still-valid token verify() only called the platform's
verify endpoint and left token_expires_at untouched, so the account stayed
inside RefreshExpiringTokens' 30-minute window and was re-read every 15
minutes until the token actually died.
On X that endpoint is GET /2/users/me, billed as a "User: Read" ($0.010 per
resource under X's pay-per-usage pricing). Simulating 24h of the scheduler
against one connected X account: 36 billed reads per day, 24 of which
renewed nothing, plus 180 minutes per day sitting on an expired token
between expiry and the next tick.
Refresh the token outright instead. A provider that hands back a fresh token
has already confirmed the credential — it rejects a revoked one with a 4xx,
which TokenRefreshClient maps to TokenExpiredException — so the verify call
adds cost and nothing else. Record the confirmation in last_verified_at, and
let the daily sweep trust it for 12 hours the way VerifyUpcomingPostConnections
already does, so it stops re-reading accounts a refresh just proved valid.
Same simulation after the change: 0 billed reads, 0 minutes expired.
Two existing tests asserted the old policy (verify-first, refresh_token left
unrotated) and now assert the new one. The rotation test still guards what
made that policy attractive: a proactive rotation must not trip a
false-positive disconnect.
* Don't disconnect an account whose access token still works
Refreshing instead of verifying removed a safety net the old verify-first
path had: if the refresh is rejected, the account was marked TokenExpired
outright. But a rejected refresh does not mean the connection is dead.
X and LinkedIn single-use their refresh_token, so a token a concurrent
refresh already consumed comes back 4xx while the current access_token keeps
working. An account with no refresh_token at all fails even earlier, without
a single call being made. Verified against main: both cases stayed Connected
before, and became TokenExpired after. PublishToSocialPlatform hard-fails
every post for a TokenExpired account, so this killed posts the access_token
would have published, up to 30 minutes before the token was actually due to
expire — and emailed the owner a disconnect notice for it.
Fall back to verifying the access token before disconnecting. This is the
only path in the job that reaches the billed verify endpoint, and only after
a refresh has already been rejected, so the healthy path stays at zero reads
(re-confirmed: 0 reads and 0 expired minutes across a simulated 24h). A
failure that can't be attributed to the token — platform down, network blip —
leaves the account alone instead of disconnecting it on noise.
Also covers two gaps found while reviewing: a lock-skipped refresh must not
record a verification it never performed, and a platform with nothing to
refresh must not be recorded as verified either. Both already behaved
correctly; they now have tests so the daily sweep can't start trusting a
stamp nobody earned.
* Only record a verification something actually proved
Review of the previous two commits turned up four issues, all in code they
introduced.
The stamp lived inside ConnectionVerifier::refreshToken(), which
refreshThenVerify() also calls — there the refresh can succeed and the verify
that follows still fail. The stamp was already written by then, vouching for a
credential nothing confirmed. Normally harmless, because the caller marks the
account TokenExpired and recentlyProvenValid() only skips Connected ones, but
markAsTokenExpired() silently no-ops when its status lock is held by a
concurrent publish. The account then stays Connected with a fresh stamp, and
both skip-windows wave it through: 40 minutes before publishing, 12 hours in
the daily sweep. Move the stamp to the caller that owns the outcome.
TokenRefreshClient classifies on HTTP status alone and never inspects the
body, so a 200 carrying an empty access_token is stored as-is and was then
recorded as healthy. (A missing key rather than an empty one can't get that
far — access_token is NOT NULL, so the write throws first.) Guard on a filled
token.
The fallback added in 9775578 called verify(), which for an already-expired
account — RefreshExpiringTokens selects those too — runs refreshThenVerify()
and re-sends the refresh_token the provider just rejected, and on Bluesky
re-runs the password re-auth AT Proto rate-limits per account. Its own
docblock claimed it only reached the verify endpoint. Add
verifyAccessToken(), which checks the stored token and nothing else.
VerifyWorkspaceConnections read last_verified_at without ever writing it, so
an account it had just confirmed healthy still burned a fresh call minutes
later when a post entered the risk window. Stamp on its success path too.
Also corrects the RefreshExpiringTokens docblock, which still described the
verify-first behaviour removed in c0d0d57.
Re-ran the 24h whole-scheduler simulation: still 0 billed reads, 0 expired
minutes, account Connected at the end.
* Fall back to the token a concurrent refresh persisted
The fallback added in 9775578 judged the in-memory access_token, which is
exactly the one that is stale when a refresh loses a race.
X single-uses the refresh_token, so when two refreshes overlap the loser's
token comes back 400 invalid_grant. Its in-memory instance still holds the
pair the winner has already rotated away, so verifying it 401s and the
account is marked TokenExpired — while the row in the database holds a
perfectly healthy token the winner just wrote.
Verified against main: a concurrent rotation leaves the account Connected
there and TokenExpired here. refreshThenVerify() already handles this by
reloading and retrying with whatever was persisted; the new path skipped that
because it never went through refreshThenVerify.
Reload before judging. This is the failure path only, so the healthy path is
untouched — the whole-scheduler simulation still reports 0 billed reads, 0
expired minutes, Connected at the end.
* Don't record a verification when the lock skipped the refresh
refreshToken() returns normally — no exception — when another process already
holds the per-account lock, so the caller can't tell "refreshed" from "did
nothing". Moving the stamp out of the verifier in 4d2795a lost that
distinction: RefreshSocialToken stamped last_verified_at on a run that made
zero HTTP calls.
The account is then vouched for by nobody: the daily sweep skips it for 12
hours and the pre-publish check for 40 minutes. If the concurrent refresh
also failed, nothing ever confirmed the credential.
Have refreshToken() report whether it actually ran. Callers that ignore the
return value are unaffected.
The test meant to cover this called refreshToken() directly rather than going
through the job, so it kept passing while the job path was broken — it now
exercises the job and asserts no HTTP call was made.
* Close the two concurrency gaps the refresh path leaves open
Both are pre-existing, but this branch raises the exposure to them from 12 to
16 refreshes per account per day.
The per-account lock lasted 30 seconds, exactly the HTTP client's default read
timeout — so a refresh could outlive the lock that protects it. Bluesky is the
worst case, refreshing with two sequential calls (refreshSession, then the
createSession re-auth), each bounded by connect + read timeouts: up to ~80
seconds under one 30-second lock. Once it lapses, a second process refreshes
with the same single-use refresh_token and one of the two is rejected. Name
the TTL, set it past the ceiling, and write down the invariant so a future
slower refresh doesn't quietly break it.
RefreshSocialToken was not unique. RefreshExpiringTokens re-selects an account
until token_expires_at moves, and that only happens once the job runs — so a
queue more than one tick behind stacked a job per tick for the same account,
each rotating a single-use refresh_token again for nothing and widening the
gap where a worker death loses the pair. Key it by account like
VerifyUpcomingPostConnections already does.
Cadence is unchanged: the whole-scheduler simulation still reports 16
refreshes, 0 billed reads and 0 expired minutes over 24h.
* Correct which providers actually single-use their refresh_token
Checked each provider's official documentation rather than carrying the
assumption forward.
LinkedIn does not rotate. Its refresh docs are explicit: "the lifespan or Time
To Live (TTL) of the refresh token remains the same as specified in the
initial OAuth flow (365 days)" — the same token comes back with a decreasing
refresh_token_expires_in, and only the access token is reissued. The claim
that it single-uses the token predates this branch, but a docblock added here
repeated it.
Bluesky does rotate, and belongs in the list instead: com.atproto.server
.refreshSession declares refreshJwt as a required output field, so every
refresh mints a new one.
Verified alongside, all matching what the code already does:
X access token 2h, refresh single-use with rotation
Bluesky refreshJwt rotates; createSession is rate-limited per handle
(30/5min, 300/day), which the fallback re-auth path shares
TikTok access 24h, refresh 365d, "may be different — you must use the
newly-returned token", which refreshTikTokToken does
LinkedIn access 60d, refresh 365d fixed, not rotated
Google does not rotate on refresh; 100 refresh tokens per account per
client, so the higher refresh rate on YouTube carries no
rotation risk
* Read X post metrics from the timeline that already returned them
Analytics fetched the account's timeline for post ids, then turned around and
looked the same ids up again through GET /2/tweets purely to read the
public_metrics the first request could have returned. The timeline call asked
for start_time, end_time and max_results — never tweet.fields.
Both endpoints bill per Post returned, so the second pass claimed the same
resources a second time, took a second round-trip, and spent a second slice of
the same rate limit. For an account with 250 posts in range that is 6 requests
where 3 will do.
The saving is in round-trips and rate limit rather than dollars: X deduplicates
a resource within a 24-hour UTC window, so the second read of an id already
read that day is not charged again. But the docs call that a soft guarantee
that "may result in resources not being deduplicated" — this stops leaning on
it for 250 resources per analytics load.
Behaviour is unchanged: same totals, same 5-page ceiling, same empty result
when the account posted nothing in range. The page cap is now a named constant,
since it bounds what one load can cost as much as how long it takes.
Adds the first tests for XAnalytics::getMetrics, covering the totals, the
pagination, and that the metrics arrive on the timeline request.
* Cover the analytics paths a happy-path test walks straight past
Three gaps in what the previous commit's tests actually assert:
A timeline page failing mid-pagination breaks out of the loop and returns
whatever was collected. Nothing pinned that — partial data beats an exception
on a dashboard someone is looking at, and a future refactor could quietly turn
it into one.
A post can come back without public_metrics. The accumulator defaults each
metric to 0, so it contributes nothing instead of erroring, which also wasn't
covered.
An account with no posts in range returns [] rather than a list of zeros, so
the UI can tell "nothing posted" from "posted, no engagement".
Also makes the routing test's mock return explicit. Without andReturn, Mockery
hands back a falsy default for the new bool return type, so the assertion about
routing was passing while silently exercising the lock-skipped branch. The test
still asserts only what its name claims, but no longer depends on a mock
default to get there.
* Close five issues an independent review found in this branch
All five sit in code these commits introduced.
Instagram and Threads must fail loudly. Their long-lived token is extended in
place and cannot be renewed once it expires, and RefreshExpiringTokens picks
them up a full day ahead precisely so there is time to react. The fallback
added in 9775578 applied to them too, so a permanently rejected extension on a
token that still reads left the account Connected — the daily sweep passed as
well, since verify() succeeds on it — and the owner learned about it only after
the token died unrecoverably, while every tick retried the rejection for 24
hours. The fallback now applies only to platforms that rotate a refresh_token,
which is what it was written for.
The lock went the wrong way. Lengthening it to 120s in 5001c9a treated the
scheduler as the only caller, but publishers wait on the same lock: one left
behind by a worker that died mid-refresh makes refreshToken() return false, and
the publisher falls through and publishes with an expired token. That window
was 30s and had become 120s. Bound the calls instead — a token endpoint answers
in milliseconds, and 8s read / 4s connect keeps even Bluesky's two sequential
calls under a 30-second lock, back to where main had it.
Reloading the account can throw. $this->account->refresh() sat outside the try
in the fallback, and an exception raised inside a catch block is not caught by
a sibling catch. With tries = 1, an account deleted mid-run put the job in
failed_jobs. VerifyUpcomingPostConnections guards this same race explicitly.
An empty access token was detected but not acted on. recordVerification()
declined to stamp it, yet the refresh still counted as a success — and the
refresh method had already pushed token_expires_at two hours out, so the
account left the window looking healthy while every publish 401d. Mark it
expired, which is what verify() used to do on the same input.
refreshToken() claimed "whether a refresh actually ran" but returned true for
platforms whose match arm does nothing. Only recordVerification() re-checking
hasTokenRefreshFlow() kept that from mattering. The guard is now explicit and
the contract true at the source.
Also settles the tweet.fields question against the live API rather than the
docs, which contradict each other: the OpenAPI spec names the parameter
post.fields, while the Fields guide and every example use tweet.fields. Both
are accepted and both return public_metrics. An unrecognised name returns 200
and silently omits the field — no error — so the name being right is load
bearing, and it is.
* Guard the match that no longer has a default arm
Dropping `default => null` from refreshToken() in af379af made the return value
honest, but it also turned a missing case into an UnhandledMatchError at
runtime. The arms and hasTokenRefreshFlow() currently agree on the same nine
platforms, and nothing enforces that: adding a platform to the predicate
without an arm would fail in production, on a queue worker, for one platform's
accounts only.
The test walks every platform claiming a refresh flow and fails by name if the
match has no arm for it. Verified it catches the real thing by temporarily
adding Facebook to the predicate — it failed with "facebook claims a refresh
flow but refreshToken() has no arm for it" — rather than trusting a green run
on code that already agrees with itself.
* Make the per-platform guard fail on a broken client chain
The test added in the previous commit swallowed every Throwable except
UnhandledMatchError, so it proved a match arm existed and nothing more. Routing
all nine refresh methods through refreshHttp() in af379af rewrote how each one
builds its request, and this test would have passed just the same if one of
those chains no longer worked.
It now fails on any exception, naming the platform, and asserts each refresh
actually put a request on the wire — a chain that breaks during construction
raises before anything is sent, so an empty recording is the signal.
Verified by breaking Pinterest's chain on purpose: "pinterest refresh threw
BadMethodCallException: Method PendingRequest::withHeadersTypo does not exist."
All nine send their request with the chains as they stand.
* Stop trading a recoverable failure for an unrecoverable one
A max-effort review found six issues, three of which undo a trade the previous
round got backwards.
Every refresh method wrote the response straight over the stored credential:
`'access_token' => data_get($data, 'access_token')`. A 200 carrying no token
therefore destroyed a working one — and on Instagram and Threads, where
refresh_token is set to the same value, both halves at once. The blank() check
added in af379af only noticed after the damage was persisted, then marked the
account TokenExpired, which RefreshExpiringTokens no longer selects — so one
glitchy-but-successful response emailed the owner and forced a manual
reconnect. Guard before the write instead, treat it as the platform
misbehaving, and the stored pair survives for the next tick to retry. The
detection branch downstream is now unreachable and gone.
Tightening the refresh timeout to 8s was the wrong fix for the lock problem.
refreshHttp() is shared with 24 publish and analytics call sites, and for X,
Bluesky and TikTok the refresh_token is single-use: abandoning a request the
provider has already processed loses the rotated pair permanently and costs the
user a reconnect. Giving up sooner makes that more likely, not less. Restore
generous timeouts and put the lock back above them. The cost of erring long is
that a worker dying mid-refresh holds the lock while a publish falls through
and retries — recoverable, unlike a lost rotation. Both constants now say which
way they are wrong on purpose.
The hasTokenRefreshFlow() guard in recordVerification() was dead: refreshToken()
already returns false for those platforms, so the branch was never entered. The
test claiming to cover it calls refreshToken() directly and never reaches it.
The command reported "Dispatched N" for a number it cannot know. dispatch()
returns a PendingDispatch whether or not ShouldBeUnique discarded it, so the
count overstated itself during exactly the backlog someone reads that line to
diagnose. It now reports accounts in the window, which is what it actually
measured.
Not changed: the daily sweep still skips accounts a refresh keeps fresh. That
is the deliberate decision this PR is built on — a refresh replaces the access
token rather than inspecting it, so there is nothing left for a billed read to
confirm.
Re-verified live after the changes: the real job still rotates the token
against api.x.com and leaves the account connected.
* Cover the tokenless-200 guard on every platform, not just X
The guard added in the previous commit protects nine refresh methods; only X
had a test. Each provider reads a different field name out of the response, so
a regression would land on one platform at a time and the suite would stay
green for the other eight.
The test drives every platform claiming a refresh flow through a 200 that
carries no token, and requires each to refuse with PlatformUnavailableException
— nothing is provably dead, so the next tick should retry rather than anyone
being disconnected — while leaving the stored credential untouched.
Verified it fails usefully by dropping the guard from one platform: 'threads
should refuse a tokenless 200 cleanly, got QueryException: null value in column
access_token violates not-null constraint'. Without naming the platform the
failure reads as an unrelated database error, since the write also poisons the
surrounding transaction.
* Stop the fallback from reading every failure as good news
A fifth review found the concurrent-refresh test passing without ever
exercising what it claims to cover, and the reason it could is a real bug.
access_token is an encrypted cast. The test wrote the winner's pair with
DB::table()->update(), which stores plaintext, so reading it back raised
DecryptException — and accessTokenStillWorks() caught Throwable and returned
true. Green test, zero coverage of the recovery that justifies the method
existing. It now writes through the model and asserts the verify call actually
happened; removing the reload makes it fail.
The catch is the bug. Treating any non-TokenExpiredException as "the token is
healthy" means an APP_KEY rotation, a corrupted column, or an UnhandledMatchError
from a newly added platform leaves the account Connected forever while every
publish hard-fails, and nobody is told. It now names the outcomes that earn the
benefit of the doubt — platform down, network dropped, account deleted mid-run
— and lets the rest surface. Loud is right here: an APP_KEY rotation breaks
every account at once, so failing the job where an operator sees it beats
disconnecting every user.
Refusing to persist a tokenless 200 also had no way out. The account kept
retrying every 15 minutes forever, and the daily sweep counts
PlatformUnavailableException as verified, so it was never disconnected and
never reported. Now the retry only continues while there is a live token
behind it: once that expires and renewal still fails, the connection is dead
in practice and says so.
Also corrects the refreshHttp() docblock, which claimed a blast radius the
private method does not have — refreshToken() is what those 24 call sites
reach — and records in VerifyWorkspaceConnections that short-TTL platforms
never being re-verified is the intended consequence, not an oversight.
* Stop a bad hour at the provider from disconnecting anyone
The escalation added last round was wrong, and two neighbouring paths had the
same shape of bug.
Marking the account expired whenever a PlatformUnavailableException hit an
already-expired token looked like it closed a silent-rot gap. But that
exception is what TokenRefreshClient raises for 5xx, 429 and connection
timeouts — so X rate-limiting for forty minutes around a two-hour token's
expiry disconnected the account, emailed the owner, and hard-failed every
scheduled post, with only the daily sweep to undo it. The rot it was meant to
prevent surfaces at publish time anyway. Reverted: a transient failure never
disconnects.
refresh_token was left unguarded when access_token was hardened. data_get()
only falls back when a key is absent, so a provider answering with an explicit
"refresh_token": null wiped the stored one — and the next tick then threw "no
refresh token available" without making a single call. Guarded in the same four
places, falling back on blank rather than on missing.
A held lock reported "nothing refreshed" even when the token was already dead,
handing the caller a credential it knew was expired. The publisher posts with
it, takes a 401, and PublishToSocialPlatform finalises the post as failed and
disconnects the account — over a lock a dying worker left behind, for the two
minutes it survives. It now says transient, which is what a refresh someone
else is already running actually is.
VerifyWorkspaceConnections promoted TokenExpired accounts back to Connected on
verifyAccount()'s return value, which is also true for "could not check, don't
disconnect". An unreachable platform therefore told owners their reconnect had
worked when nothing was verified. Promotion moved next to the successful
verify.
Each of the four is pinned by a test, and each test was checked by reverting
the fix and confirming it fails.
* Keep a lock collision off the analytics page
Round seven found one real regression from round six, one consistency gap, and
one stale comment.
Reporting lock contention as transient was right for the publish path, which
reschedules, but analytics calls refreshToken() bare and AnalyticsController
has no try/catch — and there is no renderable handler for
PlatformUnavailableException. A user opening analytics for an account whose
token expired while the scheduled job held the lock got an HTTP 500 where the
same request previously returned empty metrics. Reproduced before fixing:
"Expected response status code [200] but received 500". The controller now
degrades to empty numbers and still reports, which also covers the same 500
for any platform whose refresh 5xx'd — possible before this branch too.
The fallback verify was throwing away a result worth keeping. It is a billed
call on X and it proves the token alive exactly as a refresh does, so the
pre-publish check was paying to ask the same question minutes later. Stamped
like the other two sites.
Also rewrites a comment in rotatedTokenFrom() that described the data_get()
call it replaced rather than the blank() check beneath it. The review also
reported a false @throws on that method; it has no docblock at all.
Both fixes checked by reverting them and confirming the new tests fail.
* Degrade analytics on an unreachable platform, not on a bug
The rescue() added last commit caught Throwable, so it did not just absorb a
platform being down — it absorbed everything. A TypeError in any metrics
service rendered as "this account has no activity", with a log line as the only
sign anything was wrong. Reproduced: a metrics service throwing RuntimeException
returned 200 with empty metrics.
This is the same mistake the review flagged two rounds ago in
accessTokenStillWorks(), where treating any exception as "the token is healthy"
hid real failures. Narrowed the same way: PlatformUnavailableException and
ConnectionException degrade to empty numbers and still report, everything else
surfaces as the 500 it is.
The match moved into a named method so the intent has somewhere to live, since
the reason for the narrow catch matters more than the catch itself.
Both directions are pinned: widening the catch back to Throwable fails the
bug-is-not-hidden test, and removing the degradation fails the lock-collision
test.
* Trim the commentary back to what the code cannot say
RefreshSocialToken had 72 comment lines against 95 of code — 43% of the file.
The rest of the branch was heading the same way: two constants in
ConnectionVerifier carried twelve- and eight-line docblocks, and
VERIFIED_WITHIN_HOURS had twelve lines explaining a number.
Most of it was history rather than reasoning: what the code used to do, which
review round asked for a change, the full argument for a decision the code
already states. Kept the parts a reader cannot recover — why a catch is narrow,
why a stamp is not written inside refreshToken(), why the lock has to outlast
the timeouts — and cut the rest.
shouldTrustAWorkingAccessToken() went with it: a one-line method behind a
twelve-line docblock, now the condition it wrapped, inline where it is used.
No behaviour change; full suite unchanged at 3786.
* 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>
* Improve asynchronous social publishing reliability
* fix: resume asynchronous social publishes
* fix: preserve publish checkpoints across retries
* fix: harden resumable publish lifecycle
* fix: clean retry resources on terminal failures
* test: cover resumable social publishing edge cases
* feat: add failed post retry command
* chore: remove retry command ai rule
* fix: require confirmation for post retries
* chore: remove ai rules index
* chore: remove ai social rule
* refactor: clarify TikTok derivative path validation
* refactor: simplify social publishing retries
* refactor: further simplify social publishing retries
* refactor: retry all failed post platforms
* style: import throwable in social retries
* refactor: decouple TikTok cleanup from image format
* refactor: extract missing publish scopes
* refactor: encapsulate missing scope failure
* fix: resume failed publishes and treat Instagram rate limits as transient
Keep TikTok/Instagram checkpoints on posts:retry so a manual retry does not
start a duplicate remote post. Classify Meta BUC 400s on Instagram status
polls as retryable via GraphError.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test: cover resume paths and transient Instagram rate limits
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: resume posts:retry only for in-flight publish failures
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: resume posts:retry via ErrorCategory instead of string lists
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: handle Instagram PUBLISHED and EXPIRED container statuses
Treat EXPIRED as a terminal server error so posts:retry starts over, and complete already-published containers without a second media_publish.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: recover published Instagram stories from /stories
Stories are not on GET /{ig-user-id}/media. Resume a PUBLISHED story container from the stories edge so we do not bind a feed post id.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test: cover Instagram EXPIRED retry and published recovery paths
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: stop guessing Instagram media ids from recent /media
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: checkpoint TikTok publish_id and keep in-flight photo derivatives
Persist publish_id right after /init/ so a crash can resume without a second publish. Keep hosted photos while that id is resumable, including token expiry on status fetch; prune only after success or a confirmed remote failure.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test: cover remaining TikTok in-flight derivative edge cases
Guard the empty publish_id prune path, account guards without a checkpoint, and video status 401 after /init/.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: map TikTok publish statuses with an official enum
Use PublishStatus for status/fetch values from the Content Posting API. Keep only the documented cases, including FAILED as the terminal failure.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: share in-flight publish checkpoint keys
Read TikTok and Instagram resume state through one helper so publishers, posts:retry, and derivative cleanup agree on the same keys.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: retry Instagram media_publish after transient Graph failures
A 500/code 2 after Meta already published left the job Failed as unknown.
Treat that as still-processing so resume can confirm PUBLISHED instead of posting again.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: resume Instagram publish after dropped Graph connections
A timeout or connection reset after Meta already published was marked unknown.
Treat it as still-processing so resume can confirm PUBLISHED instead of posting again.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat: fire user.signed_up, checkout.started, checkout.completed from the backend
These 3 PostHog conversion events only fired client-side (useTracking.ts),
so ad blockers and cut-short page unloads could drop them the same way
they were dropping the GTM/ad-platform click IDs. Moves the PostHog side
to the backend, same reliability rationale, same touchpoints already
established for the click-id work:
- user.signed_up: App\Actions\User\CreateUser, right after SyncUser is
dispatched, gated on !is_invite. auth_provider derived from
google_id/github_id presence, same values the frontend session-based
flow used.
- checkout.started: WelcomeController::storeReferralSource, alongside the
existing WelcomeEvent::Referral capture, right before checkout starts.
- checkout.completed: new TrackCheckoutCompleted job, dispatched from
StripeEventListener::handleSubscriptionCreated (webhook-driven — more
reliable than the old frontend flow, which depended on the user staying
on billing/Processing.vue). Conversion value/currency/transaction_id
read from the subscription webhook payload; transaction_id is the
Stripe subscription id rather than the old Checkout Session id.
Two new enums (UserEvent, CheckoutEvent) follow the existing per-domain
PostHog event enum convention (WelcomeEvent, BillingEvent, PostEvent).
useTracking.ts keeps its GTM dataLayer pushes (untouched, separate
concern) and drops only the captureEvent(...) calls for these 3 events —
PostHog already had CreateUser/WelcomeController/StripeEventListener as
established backend touchpoints, so this reuses them instead of adding
new infrastructure.
* chore: remove now-dead GTM dataLayer pushes from useTracking.ts
All 3 conversion events (sign_up, begin_checkout, purchase) now go to
PostHog exclusively from the backend, and PostHog is the single source
feeding Meta/Google/LinkedIn/etc ad destinations (not GTM). The
dataLayer.push(...) calls in useTracking.ts had no consumer left, so the
composable is now fully dead — deleted, along with its 3 call sites.
Each call site's surrounding scaffolding that existed only to support the
tracking call was simplified alongside it: ReferralSource.vue's submit()
no longer needs the onStart/onError/onHttpException/onFinish dance (that
was only there to gate trackBeginCheckout), and Processing.vue's
completePurchase() no longer reads auth.plan just to pass it to
trackPurchase().
datalayer.ts is untouched — it only pushes context variables (user name/
email, account/workspace name) that Crisp reads, not events.
* feat: split checkout.completed into trial.started / checkout.completed / trial.converted
checkout.completed used to fire on every customer.subscription.created
regardless of the resulting status, conflating two different business
events: a card-required trial starting (status trialing, no charge yet)
and an immediate paid subscription starting (status active — first-month
coupon or no trial). These are now separate PostHog events:
- trial.started: subscription created with status trialing. No
conversion_value (nothing has been charged) — carries trial_ends_at
instead.
- checkout.completed: subscription created with status active (coupon or
immediate full-price checkout) — unchanged behavior, still carries
conversion_value/currency/transaction_id.
- trial.converted (new): the trial's first successful charge, detected on
customer.subscription.updated via Stripe's own previous_attributes.status
transitioning from trialing to active. This is the Stripe-recommended way
to detect what changed in an .updated webhook, and doesn't depend on our
own DB write ordering — Cashier's WebhookController dispatches
WebhookReceived before it syncs the local subscription row, so trusting
our own stripe_status here would be fragile.
TrackCheckoutCompleted and the new TrackTrialConverted share their
plan/interval/persona/conversion_* property computation via
App\Support\StripeSubscriptionConversion (same shape, two different
moments in the billing lifecycle) instead of duplicating it.
Deliberately out of scope per product decision: trial-expired-without-
converting tracking (signups minus conversions already gives that number),
and the async-payment-method incomplete status edge case (card/debit only,
Stripe Checkout resolves 3DS inline before redirecting back — incomplete
essentially can't happen in this flow).
* refactor: derive auth_provider from the created User model, not the input array
$user already has google_id/github_id populated (they were passed straight
into User::create() a few lines above), so re-reading them from $data was
redundant — same information, extra indirection.
* fix: OAuth signup silently drops pending invites and bypasses the self-hosted registration gate
Found while reviewing why CreateUser's `! $isInviteRegistration` PostHog
gate never actually excluded anyone via Google/GitHub — because is_invite
was always false for OAuth registrations, regardless of whether the
person arrived from an invite link. Two real, pre-existing bugs:
1. SocialLogin.vue's Google/GitHub buttons linked to the OAuth redirect
routes with no query params at all — invite, redirect and email were
silently dropped the moment someone clicked "Sign up with Google"
instead of using the email form. The person got a brand-new
independent account + workspace instead of joining the inviter's
account; the invite itself sat unaccepted with zero feedback.
2. /auth/google/redirect and /auth/github/redirect were never wrapped in
the `registration.enabled` middleware that gates /register in
self-hosted mode — so self-hosted installs could be signed up into via
OAuth with no invite at all, bypassing the intended lock.
Fix:
- New PreservesInviteRedirect trait carries `invite`/`redirect` across the
OAuth round-trip via session (PreservesAttributionParameters' pattern,
but kept separate since this isn't marketing data).
- SocialLogin.vue now forwards `redirect`/`invite` from its parent page
onto the Google/GitHub links; Register.vue and Login.vue pass their
props through.
- registerNewUser() now passes the same `is_invite` semantics
RegisterRequest already uses for the email flow, and both
registerNewUser()/loginExistingUser() honor the pending redirect (same
target AcceptInvite.vue already sends the email flow to), so accepting
via OAuth now lands back on the invite page authenticated, exactly like
email/password does — no auto-accept, same explicit-consent UX.
- The self-hosted gate can only be enforced in registerNewUser() (after
the callback resolves an identity) since /redirect is shared with
login and can't tell new vs. returning users apart beforehand.
New App\Models\Invite::fromId() (safe UUID-checked lookup) and
App\Support\SafeInternalRedirect (same-app-path-only check) replace
duplicated inline logic in RegisterRequest, RegisteredUserController and
AuthenticatedSessionController, and are now shared with the OAuth path
too.
* refactor: replace client-supplied redirect param with server-resolved invite redirect
Never trust a redirect URL from the client. Login/register/OAuth now only
accept an invite id (already validated via Invite::fromId()) and derive the
return-to-invite route server-side, eliminating the open-redirect surface
instead of validating around it.
* refactor: use Request::string() for invite id, trim comments
Str::isNotEmpty()/toString() replace manual is_string/empty checks.
Also cut oversized inline comments down to one line each.
* refactor: tighten Invite::fromId, drop redundant is_string check
* test: cover GitHub invite acceptance and self-hosted gate scenarios
Mirrors the existing Google coverage — GitHubController has the same
invite-completion and self-hosted-gate logic but only Google had tests for it.
* refactor: fold null-account check into owner_id guard via nullsafe operator
* refactor: dedupe Stripe conversion tracking jobs and properties
TrackCheckoutCompleted, TrackTrialStarted, and TrackTrialConverted shared
near-identical boilerplate (guard clause, capture call, tries/timeout).
Extracted AbstractTrackStripeSubscriptionEvent so each job only declares its
event name and properties. StripeSubscriptionConversion now exposes
baseProperties() (plan_name/interval/persona) shared by all three, with
propertiesFor() adding conversion_* on top for the two charge-backed events.
* refactor: extract named status helpers in StripeEventListener
currentStatus()/wasTrialing()/isNowActive() replace inline data_get()
comparisons in trackSubscriptionStart() and trackTrialConversion().
* refactor: drop redundant persona from Stripe PostHog event properties
Persona is already set as a person property via identify() during
onboarding, so it is joinable on every event without repeating it —
sending it again on every billing capture was dead weight.
* refactor: drop redundant plan property in TrackBilling
PostHogService::capture() already injects 'plan' from $account when an
account is passed — the manual key was silently overwritten by the
identical value.
* feat: log PostHog payloads to laravel.log in local environment
Lets capture()/identify()/groupIdentify() be verified from laravel.log
during local testing (e.g. signup, invite flows) without a real PostHog
API key configured. Logging is independent of isEnabled() — the actual
dispatch to PostHog stays gated on it as before.
* fix: cold-review pass — dead code, ordering bug, missing test coverage
- Fire checkout.started only after the price-ID guard, not before it, so a
misconfigured plan can't record a phantom checkout.started for a checkout
that never starts (WelcomeController).
- Reorder OAuth registerNewUser() so the destructive session pull of
attribution parameters happens after the self-hosted invite gate, not
before — a rejected attempt no longer discards UTM/click-id attribution
(GoogleController, GitHubController).
- Delete the SignupSuccess page/controller/route entirely: it only ever
displayed a 5s cosmetic transition before redirecting home, its tracking
call was already removed, and app.calendar's own middleware handles
onboarding redirects regardless of entry point. The 3 post-registration
redirects now go straight to app.welcome (was silently dropped to
app.home in an earlier pass of this cleanup — welcome is correct, that
was the whole point of the intermediate page).
- Remove dead code left behind by the useTracking.ts removal: unused
persona/conversion props (and the Stripe API call in BillingController
that only existed to populate them), unused auth_provider session flash
across 3 controllers, unused captureEvent() export in posthog.ts, and
unused RegisterRequest::isInviteRegistration().
- Add missing test coverage: login with a valid/unknown invite param
(AuthenticatedSessionController's invite-redirect branch had zero
coverage), and a regression test locking in the checkout.started
ordering fix.
* fix: second cold-review pass — invite email mismatch, stale session leak, null interval bug
- Reject OAuth registration (Google/GitHub) when the invite's email doesn't
match the authenticated provider account's email, mirroring the check
RegisterRequest already enforces for the web form. Previously an invite
for one email could be completed by signing in with a different Google/
GitHub account, leaving a permanently workspace-less orphaned account
(AcceptInvite's WrongEmail path never runs the shell-account cleanup,
since that only fires on Result::Accepted).
- Fix PreservesInvite::storeInvite() to always overwrite the session value
(matching PreservesAttributionParameters, which it claimed to mirror but
didn't). It previously only wrote when the invite param was present,
so a stale invite id from an aborted OAuth attempt could leak into a
later, unrelated login/registration in the same session.
- Fix StripeSubscriptionConversion::baseProperties() mislabeling a
conversion as 'yearly' when both the webhook price id and the plan's
stripe_yearly_price_id are null (null === null) — now requires the plan
price id to be non-null before comparing, matching the equivalent guard
in App\Support\BillingCycle::intervalMonths().
- Remove the fully dead fromCheckout/Cache::add mechanism in
BillingController::processing() — its only consumer (the frontend
trackPurchase call) was already deleted earlier in this PR.
- Drop the unused owner eager-load in AbstractTrackStripeSubscriptionEvent
and TrackBilling — neither reads $account->owner, only owner_id.
* fix: normalize invite email casing at creation; resolve PostHogService via container
- CreateInvite::execute() now lowercases the invite email before storing it.
Invite acceptance/decline/registration all compare it verbatim against
User.email (itself always lowercase), so a mismatched-case invite created
before this fix could otherwise never be accepted by its own recipient.
- CreateUser::execute() resolves PostHogService from the container instead
of `new PostHogService`, matching the DI pattern used by every other
PostHog call site added in this PR.
* fix: validate self-hosted invites against the DB; enforce OAuth provider toggles server-side; count past_due recovery as a trial conversion
- EnsureRegistrationEnabled, GoogleController, and GitHubController now
require the invite param to resolve to a real Invite (Invite::fromId())
instead of just checking presence. Previously any random string/UUID
satisfied the self-hosted "invite required" gate and produced a fully
functional account with its own workspace, defeating the restriction
entirely.
- google_auth_enabled/github_auth_enabled were only ever read on the
frontend to show/hide the login button — the actual OAuth routes
(GoogleController/GitHubController::redirect(), and the settings
connect-provider endpoint) had no backend check, so a disabled provider
could still be used end-to-end by hitting the URL directly. Both are now
gated with abort_unless(..., 404). The settings Authentication page also
stops rendering a "Connect" button for a disabled, not-yet-connected
provider.
- StripeEventListener::trackTrialConversion now also fires trial.converted
on a past_due -> active recovery (a trial's first charge attempt failing
and then succeeding on retry), not just the immediate trialing -> active
transition. Guarded by trial_end being set so a long-time paying
customer's unrelated payment-method recovery is never miscounted as a
trial conversion.
* refactor: merge the two connectProvider abort_unless checks into one
* refactor: centralize social auth providers in a SocialAuthProvider enum
google/github were each hand-checked against config("trypost.{provider}_auth_enabled")
independently in GoogleController, GitHubController, AuthenticationController
(3 different shapes: hardcoded config key, in_array against a private const
array, and a duplicated string list for labels), plus a fourth copy of the
enabled flags in HandleInertiaRequests. Adding a provider meant touching all
of them by hand.
App\Enums\Auth\SocialAuthProvider is now the single source of truth: cases()
replaces the PROVIDERS const array everywhere it was iterated, label()
replaces the hand-written label map, and isEnabled() replaces every direct
config() call. AuthenticationController::connectProvider() collapses its two
abort_unless checks into one via tryFrom()?->isEnabled().
* refactor: add User::isConnectedTo() and drop the manual foreach in canDisconnect()
The same "{$provider}_id" dynamic-property pattern was hand-written in three
places in AuthenticationController (disconnectProvider's column lookup,
getConnectedAccounts' connected flag, canDisconnect's loop). User::isConnectedTo()
centralizes it, and canDisconnect() now reads as a single collection pipeline
("is there some other connected provider or a password") instead of a
counter-then-compare loop. disconnectProvider() also switches to the
already-resolved SocialAuthProvider throughout instead of re-deriving from
the raw string, and its flash message now uses ->label() instead of
ucfirst($provider) (which mis-cased "github" as "Github" instead of "GitHub").
* refactor: remove the fixed 5s post-checkout redirect delay
REDIRECT_DELAY_MS existed to give a client-side PostHog/ad-pixel capture
call time to flush before navigating away. That call was removed earlier in
this PR (checkout.completed now fires from the Stripe webhook, server-side,
independent of this page), so the delay had nothing left to wait for —
navigate immediately once the poll confirms subscriptionActive.
* refactor: extract SocialProvider type instead of repeating the 'google' | 'github' union
* fix: Login.vue never displayed session-flashed email errors
GoogleController/GitHubController flash OAuth failures (wrong invite email,
GitHub email unavailable) via redirect()->route('login')->withErrors([...]).
That lands as page.props.errors (Inertia's page-level error bag), not as
the <Form> component's own local submission errors — so the InputError
bound to errors.email never showed it, silently swallowing the redirect's
whole point. Falls back to usePageErrors() (already used elsewhere in the
app for this exact scenario) when the form's own errors are empty.
* test: add a browser test for the Login.vue flashed-error display fix
Pest feature tests can only assert session state, not what actually renders
— this drives a real browser through the OAuth invite-email-mismatch
redirect and asserts the error text is visible on /login. Confirmed it
fails without the Login.vue fix (assertSee fails at the expected point)
and passes with it restored.
* fix: PostHog debug logging silently skipped by redundant isEnabled() pre-checks
signup, trial, and billing events never reached PostHogService::capture()
locally because CreateUser and StripeEventListener short-circuited on
isEnabled() before the local-logging path in capture() could run. Added
shouldTrack() (isEnabled() || local environment) and applied it at every
dispatch/handle guard in the chain, while the real API call in SendEvent
stays gated on isEnabled() alone so production behavior is unchanged.
* fix: correctly guard past_due trial-conversion recovery against a later unrelated payment retry
convertedFromTrial() used trial_end being non-null to detect a past_due ->
active recovery as a trial conversion, but Stripe never clears trial_end
once set, so the guard could never actually exclude a long-time paying
customer's unrelated card-decline recovery months later — it would fire
trial.converted again, double-counting conversion_value. Now compares the
subscription item's current_period_start against trial_end, which only
match for the trial's own first billing period.
Also reverts the CreateInvite.php Str::lower() normalization added earlier
in this branch — invite emails are stored and compared as submitted, with
no manual casing normalization anywhere.
Adds a diagnostic log in trackTrialConversion() (unconditional, not gated
on shouldTrack()) to verify this against a real Stripe webhook payload via
a test-clock walkthrough.
* fix: don't fire checkout.started before the Stripe checkout session actually exists; drop diagnostic logging
WelcomeController::storeReferralSource captured checkout.started before
calling StartSubscriptionCheckout::redirect(), so a failure creating the
Stripe session (e.g. the coupon/promo-code conflict ConfigureSubscription
Checkout throws on, or any Stripe API error) still left a false-positive
conversion event in PostHog. redirect() now runs first; the capture only
fires once the checkout session was actually created.
Also removes the unconditional Log::info() added to trackTrialConversion()
for the manual Stripe test-clock verification — the current_period_start
fix it was added to confirm has now been validated against a real webhook
payload, so it's no longer needed and shouldn't keep logging on every
production subscription.updated event.
* refactor: centralize OAuth invite-registration validation in PreservesInvite
GoogleController and GitHubController each duplicated the same self-hosted
registration gate and invite-email-mismatch check verbatim. Moved both into
resolveInviteForRegistration() and inviteEmailMismatchRedirect() on the
shared PreservesInvite trait so a future OAuth provider (or an edit to one
controller) can't silently drift from the other on these security-relevant
checks.
* feat: capture ad click IDs for Meta/Google/LinkedIn/TikTok/Reddit/Pinterest attribution
Adds gclid, fbclid, li_fat_id, ttclid, rdt_cid, and epik columns to users,
captured the same way UTM parameters already are (query string -> session
-> persisted on signup, surviving the OAuth redirect round-trip via the
new PreservesClickIds trait).
Forwards them as first-touch ($set_once) PostHog person properties in
SyncUser, so PostHog's native ad-platform destinations (Meta Ads
Conversions API, Google Ads Conversions, LinkedIn Ads, TikTok Ads, Reddit
Ads, Pinterest) have first-party click IDs to match conversions back to
the originating ad click.
* refactor: unify PreservesUtmParameters and PreservesClickIds into one trait
Both traits captured a set of query-string keys into the session and
retrieved them at signup, with identical extract/store/retrieve logic and
every call site always using both together — the split added no real
separation, just duplicated the same mechanism twice.
PreservesAttributionParameters replaces both with a single ATTRIBUTION_KEYS
list and one session key. Adding a future ad network's click ID is now one
line in that list instead of a second trait.
* refactor: split UTM_KEYS and CLICK_ID_KEYS into separate constants
Same single trait, single session key, single extract/store/retrieve
mechanism — just two named arrays instead of one merged list, so it's
clear at a glance which key belongs to which category.
* fix: don't truncate ad click IDs to 255 chars, only UTM parameters
Ad platforms explicitly warn against assuming a fixed max length for
click IDs (Google: gclid has already grown from 26 to 100+ chars, and
their docs say never truncate or validate against a fixed length).
Truncating would silently corrupt the value into something that no
longer matches the real click ID, which is worse than not capturing it
at all.
Widens the click-id columns from string (VARCHAR 255) to text — safe to
edit the migration in place since it hasn't shipped to production yet.
UTM parameters still get truncated to 255, since those are ours (our own
campaign URLs) and the column stays VARCHAR(255).
* refactor: use Laravel collection/Str helpers, forward UTMs to PostHog too
- extractAttributionParameters now reads through collect()/Str::limit()
instead of raw array_filter/array_map/mb_substr; storeAttributionParameters
drops its now-redundant emptiness check since retrieveAttributionParameters
already treats "absent" and "present-but-empty" the same via pull()'s
default.
- SyncUser forwards utm_source/medium/campaign/term/content alongside the
click ids as first-touch ($set_once) PostHog person properties. UTMs were
never sent to PostHog before this, on any prior code — now that PostHog is
the source of truth for ad-platform attribution, it should have the full
picture, not just click ids.
- Adds the missing GitHub-existing-user click-id session test, mirroring
the Google one (parity with the existing UTM coverage).
* fix: 3 issues found by review — empty-string leak, duplicated key list, comment style
- extractAttributionParameters no longer keeps an empty-string value (e.g.
?utm_source=&gclid=, which some ad/email templates always append even
for unfilled slots). The refactor to collect()/Str::limit() a few
commits back dropped the outer array_filter() that used to strip these,
so they were slipping into User::create() as '' instead of staying
null. Restored via a trailing ->filter() on the merged result, and
extended the same protection to click ids (which never had it, even
before that refactor).
- New App\Support\AttributionKeys centralizes the UTM_KEYS/CLICK_ID_KEYS
lists that PreservesAttributionParameters and SyncUser each maintained
independently. SyncUser previously hand-listed the same 11 field names
as a second array with no shared source of truth — a future ad network
added to the trait would silently never reach PostHog unless someone
remembered to update this second copy too.
- Removed the // comment block from the click-id migration explaining the
text-column rationale — CLAUDE.md's PHP rules reserve inline comments
for exceptionally complex logic; the rationale already lives in the
commit message that introduced it.
* chore: gitignore .superpowers/ scratch workspace
Holds per-plan subagent-driven-development artifacts (ledger, briefs,
review packages) — scratch state, not part of the shipped codebase.
* feat: add connection_warning_sent_at to post_platforms
* feat: add PostAtRisk notification type and translations
* fix: add user_id to NotificationPreferenceFactory definition for ->create() support
* feat: add PostAtRisk mailable and email template
* feat: add VerifyUpcomingPostConnections job
* fix: guard VerifyUpcomingPostConnections against transient errors and cross-workspace leaks
- Add a generic \Exception catch around ConnectionVerifier::verify() so a
transient error (e.g. ConnectionException) on one account can't abort
processing of every other at-risk account in the workspace run.
- Eager-load socialAccount.workspace so markAsTokenExpired's observer chain
never lazy-loads it — this only ever manifested once 2+ distinct accounts
were hydrated in a single run (Eloquent only sets preventsLazyLoading on
batch hydration of >1 row), which is exactly the multi-account scenario
this job exists to handle.
- Add covering tests: enabled=false posts are excluded, one workspace's
at-risk posts never leak into another workspace's notification, and an
unexpected exception on one account doesn't stop the rest of the run.
* feat: add social:check-upcoming-connections command and schedule it
* fix: add composite index for the 15-minute upcoming-post connection query
post_platforms(status, connection_warning_sent_at) supports the filter both
VerifyUpcomingPostConnections and social:check-upcoming-connections run every
15 minutes; without it, every run does a full table scan that only grows as
posts accumulate.
* fix: localize the PostAtRisk email's per-account line and label times as UTC
The postsLabel line was the only hardcoded-English content in an otherwise
fully-translated email, and it showed scheduled_at times with no timezone
indicator even though the app stores everything in UTC. Add
mail.post_at_risk.posts_label (pluralized, one entry per locale, mirroring
each locale's existing post_at_risk.subject plural-boundary syntax) and use
trans_choice() to build the line, with a literal " UTC" suffix left
untranslated in every locale like a unit abbreviation.
Also document why content() reassigns the public $atRiskGroups property
instead of using a local variable (Mailable::buildViewData() overwrites
with() data with same-named public properties).
* fix: time-box the warning dedup and guard against orphaned/ownerless rows
- Re-arm connection_warning_sent_at after a day instead of permanently
suppressing it, so a post rescheduled back into the risk window after a
stale warning is re-evaluated instead of silently skipped forever.
- Exclude post_platforms with a null social_account_id from the at-risk
query. With tries=1, dereferencing a null socialAccount relation would
abort the whole workspace run, including already-detected broken accounts.
- Resolve and check the workspace owner before stamping
connection_warning_sent_at, so an ownerless workspace's posts are left
un-warned (available to be picked up once it gets an owner) instead of
being marked "warned" with no notification ever sent.
Applied the same dedup time-boxing and null-account guard to the
social:check-upcoming-connections dispatch query for consistency.
* fix: PostAtRisk email is always English — drop the locale translation layer
config('app.locale')/App::setLocale() is only ever set by the SetLocale
web middleware, which reads a cookie off the incoming HTTP request. Every
Mailable in this branch is built inside a queued job (SendNotification),
which runs outside the HTTP request lifecycle entirely — no middleware,
no cookie, nothing sets the locale there. So content() always resolved
'app.locale' to the static APP_LOCALE default ('en') regardless of the
recipient's actual preference: the 16-locale mail.post_at_risk.* keys
were dead weight from the start, matching an existing (pre-existing,
out of scope here) gap in the sibling WorkspaceConnectionsDisconnected/
AccountDisconnected mailables.
Replaces the trans_choice()/__() calls with plain English strings built
directly in PostAtRisk, and removes the now-unused mail.post_at_risk.*
block from all 16 locale files. Also strengthens the mailable test to
assert the full "N post(s) scheduled: ... UTC" string, not just a
fragment of it.
* refactor: consolidate the two post_platforms migrations from this branch into one
connection_warning_sent_at and its supporting index were added in two
separate migrations (the column in the original task, the index during
final review). Both are still unmerged/unshipped on this branch, so
folding the index into the same migration that adds the column is safe
and keeps the schema change to post_platforms as one unit instead of two.
Verified with a full rollback + re-migrate cycle that the consolidated
up()/down() is self-consistent.
* refactor: add PostPlatform::scopeEnabled(), replace ->where('enabled', true) everywhere
The raw where('enabled', true) clause was duplicated across 17 call sites
in 12 files (13 including the 2 this branch added), all expressing the
same rule PublishPost enforces at publish time: only enabled platforms
are eligible. Added a scopeEnabled() to PostPlatform and swapped every
query-builder call site to ->enabled().
Three call sites are intentionally left untouched: they filter an
already-loaded relation Collection (->postPlatforms->where(...), no
parens), which is Collection::where(), not a query scope — a query scope
can't apply to an in-memory collection.
No inverse (enabled = false) query pattern exists anywhere in the
codebase — 'enabled' => false only ever appears as a write when a post
is disabled/synced, never as a read filter — so no scopeDisabled() was
added; nothing would call it.
* test: cover re-armed post_platform where the account was reconnected
The re-arm dedup fix (connection_warning_sent_at older than a day is
treated as null) only had coverage for "still broken, warns again" and
"too recent, stays skipped". Missing: the row gets re-evaluated (verify()
is called, not skipped) but comes back healthy because the user
reconnected in the meantime — nothing should change (no new warning, no
notification, marker stays at its old value).
* fix: dispatch-level uniqueness, index the enabled filter, close markAsTokenExpired race
From a deep review pass on the whole branch:
- VerifyUpcomingPostConnections now implements ShouldBeUnique (keyed on
workspaceId, 300s window). withoutOverlapping() on the schedule only
serializes the fast-dispatching command; a queue backlog could still let
two jobs for the same workspace run concurrently, both mailing the owner
for the same at-risk posts.
- The composite index now covers enabled too (status, enabled,
connection_warning_sent_at) — every query that uses it filters on all
three, so the index previously required a heap fetch per row just to
check enabled.
- markAsTokenExpired() silently no-ops if it loses the account's status
lock to a concurrent process (a publish attempt, the daily check). The
job used to push the account into the at-risk notification regardless
of whether the update actually landed. It now re-checks the account's
status after the call and only warns if the transition is confirmed —
a lost race just defers the account to the next run instead of sending
a misleading "reconnect" email for an account whose status didn't change.
Also includes an unrelated stray Pint fix (inline \Throwable -> imported)
in SendNotification.php that had been sitting uncommitted.
* refactor: centralize account handle/display name, expose to frontend, close review findings
Adds SocialAccount::handle()/accountDisplayName() plus appended
display_label/handle_label JSON fields, replacing duplicated
username/display_name fallback logic scattered across platform
previews, NetworkConnectGrid, PreviewTab, Calendar, and the post
editor pages.
Also closes the remaining findings from the final review on this
branch: escapes the workspace name in PostAtRisk's intro (and drops
the now-unnecessary raw-HTML rendering), fixes the tautological
"dispatches once per workspace" test, adds plural/subject test
coverage for PostAtRisk, raises VerifyUpcomingPostConnections'
uniqueFor to cover the full schedule cadence, and updates a stale
docblock.
* test: cover draft-post exclusion, account status after PlatformUnavailableException
Adds the two coverage gaps left open by the last review: a post still
in Draft status inside the 1-hour window must not trigger a check or
warning, and a PlatformUnavailableException must leave the account
status untouched. Also drops the dedicated PostAtRisk XSS test — the
intro is now plain Blade-escaped text, so the coverage is redundant
with the framework's own escaping.
* fix: close final review findings — i18n notification, empty-string fallback, missed refactor sites
- Localize the in-app "post at risk" notification title in all 16
locales via trans_choice (the email stays English, unchanged)
- Use ?: instead of ?? in handle()/accountDisplayName()/handleLabel()
so an empty-string username/display_name still falls back, matching
the old Vue || behavior
- Migrate the 3 frontend sites the earlier sweep missed (Index.vue,
SocialAccountsGrid.vue, ScheduleTab.vue) to display_label/handle_label
- Fix avatar-initial fallback in the platform preview components to use
display_label instead of raw display_name
- Correct handle_label's TS type to string | null across 10 files to
match the accessor's actual return type
- Add test coverage for the command-level "already warned" dedup path
and the in-app Notification row created alongside PostAtRisk's email
* fix: notification storm, duplicate-email race, and queue payload bloat in upcoming-post checks
Three correctness issues found by review, fixed after discussion:
- An already-broken account could get a fresh PostAtRisk email every
15 minutes for as long as it stayed broken, if new posts kept
entering the 1-hour risk window. Gated with a per-account 60-minute
renotify cooldown.
- Two concurrent jobs (RefreshExpiringTokens and this one) could each
discover the same dead token and send their own email for it
(AccountDisconnected + PostAtRisk) within the same tick. Gated with
a 5-minute grace period, applied only when another process already
transitioned the account before we got to it — not when we're the
one making the transition.
- PostAtRisk carried full SocialAccount/PostPlatform/Post model
graphs on the queue payload, since SerializesModels can't reduce
models nested inside a plain array/Collection to lightweight
identifiers. It now carries only post_platform IDs and rehydrates
at send time, with envelope()/content() sharing one memoized query
so their counts can't disagree.
Also replaces the account-health cache with a persisted
SocialAccount.last_verified_at column, and narrows the actual
platform API calls to only fire once a post's nearest scheduled_at
is within 30 minutes — enough lead time to reconnect, without
spending API budget checking a full hour out.
* fix: replace dead unsubscribe link with notification preferences, finish display_label sweep
The shared mail footer's unsubscribe link was permanently dead code
(unsubscribe_url was never passed by any Mailable). Replaced it with
a fixed "Manage notifications" link to the real settings page,
via route('app.notifications.preferences').
Also closes out the remaining sites still computing the
username/display_name fallback locally instead of reading the
backend-computed display_label: 8 more Vue components (platform
previews, per-platform post-editor settings, the AI post wizard, the
automation Generate node config, and the analytics account selector)
plus two PHP call sites (PostPlatform::getDisplayNameAttribute(),
already fixed on main before this branch, and the template image
generator's rendered footer text).
* fix: only show "Manage notifications" on preference-driven emails
The link doesn't make sense on transactional emails that always send
regardless of notification preferences (password reset, email
verification) or that go to recipients who may not even have an
account yet (workspace invite) — and the settings page it points to
requires login, which is actively broken for the first two.
Split the shared footer into two Maizzle components: footer.html
(plain) for the 3 transactional templates, footer-authenticated.html
(adds the link) for the 6 that go through SendNotification and
respect the recipient's notification preferences.
* fix: lock PostAtRisk's subject to the dispatch-time count, expose handle_label from analytics
PostAtRisk's subject/previewText were recomputed from a fresh DB
query at send time, while the in-app notification's title (built in
VerifyUpcomingPostConnections::notifyOwner()) used the count observed
at dispatch time. If a post_platform row disappeared in between, the
two could disagree. The count is now passed into the mailable
explicitly and reused for both — the body's account/post details
still rehydrate fresh from the DB, preserving the anti-staleness fix
from earlier in this branch.
Also adds handle_label to AnalyticsController's account payload,
matching every other endpoint that serializes a SocialAccount.
* fix: don't abort the whole workspace run if an account is deleted mid-verify
An exception thrown inside a catch block isn't routed to a sibling
catch, so $account->refresh() throwing ModelNotFoundException (the
user disconnected/deleted the account in the brief window between
this job loading it and handling the TokenExpiredException) escaped
handle() entirely. With tries = 1, that killed the run for every
other account in the same workspace, not just the deleted one.
Also fixes an inconsistent placeholder in PlatformPreview.vue
(handle_label: null instead of '', matching display_label).
* fix: guard against deleted accounts, guarantee a non-empty account name
Closes the last 4 findings from the sixth review round:
- VerifyUpcomingPostConnections now skips a group whose account
resolved to null (deleted between the main query and its eager-loaded
relation), instead of an unguarded property access aborting the
whole workspace's run
- the same job's nested exception handler now covers any \Exception
from markAsTokenExpired() (lock/DB failures), not just
ModelNotFoundException
- PostAtRisk drops a rehydrated group whose account no longer exists
instead of crashing the render (verified: fails without the fix,
passes with it)
- AnalyticsController's handle_label field is now actually consumed by
AnalyticsAccountSelector.vue instead of being unused payload
Also closes a real gap: every connector requests enough OAuth scope to
populate at least one of username/display_name (confirmed for TikTok,
whose account.py comment implied otherwise but whose connect() scopes
always include user.info.profile), so accountDisplayName()/handle()/
displayLabel/handleLabel now return a guaranteed non-empty string
(falling back to the platform label only as a last resort) instead of
being nullable. This removes the now-pointless @if guards around
accountDisplayName() in the account-disconnected and post-at-risk
email templates, and lets ~30 frontend files drop the `| null` from
display_label/handle_label and the ?? undefined fallbacks that only
existed to satisfy that type.
* fix: drop the now-pointless ?? '' fallback on display_label in TemplateImageGenerator
display_label is a guaranteed non-empty string (see 950558b4).
* fix: correct social_account's TS type to nullable in Index.vue and Calendar.vue
Both declared social_account as required while their own templates
used optional chaining (pp.social_account?.display_label) — the type
was lying. social_account_id is nullable and the account can be
deleted (FK is nullOnDelete), so the field genuinely can be null.
Swept every other social_account/socialAccount field in resources/js
for the same mismatch; all others already declared it correctly.
* Centralize avatar-initial extraction via getInitials()
Replace hand-rolled .charAt(0)/.charAt(0).toUpperCase() avatar-initial
logic across social account previews, the accounts grid, the analytics
account selector, and the mention picker with the existing
useInitials() composable already used by Avatar.vue.
* Drop pointless display_label fallbacks now that it's always populated
display_label is guaranteed non-empty (falls back to the platform
label server-side), so || 'Channel' / || 'TryPost' / ?? platform were
unreachable.
* Fix cold-review findings: dead handle_label guard, slug leak, wrong post count
- AnalyticsAccountSelector: the "@handle" line's guard/value must read the
raw username (nullable — Facebook Pages and Telegram channels legitimately
have none), not handle_label, which always resolves to something and made
the guard permanently true. Drop the now-orphaned handle_label field from
the analytics payload/type since nothing else in analytics used it.
- PlatformPreview: the no-account-selected fallback now uses
getPlatformLabel() instead of the raw platform slug, matching the
backend's own last-resort label fallback.
- VerifyUpcomingPostConnections: count distinct posts (post_id), not
post_platform rows, so one post spanning multiple broken accounts doesn't
inflate the at-risk count in the email subject and notification title.
* Fix cold-review round 2: silent Telegram/Discord false negative, flaky email ordering, dead display_name
- VerifyUpcomingPostConnections: ConnectionVerifier::verify() reports a
dead Telegram/Discord connection by returning false rather than
throwing. The job discarded that return value, so a bot removed from
a channel/guild was stamped last_verified_at and silently trusted
healthy for the next 40 minutes — no warning, post just fails at
publish time. Route a false return through the same
TokenExpiredException handling used by every other platform.
- PostAtRisk: atRiskGroups() had no ORDER BY, so the per-account
"N posts scheduled: H:i, H:i UTC" line rendered in arbitrary
(physical row) order. Sort by scheduled_at before formatting.
- Drop the orphaned display_name field from the analytics payload/type
(superseded by display_label; nothing in resources/js/components/
analytics or pages/analytics read it).
* Add social icons and copyright to email footers
Icons match the trypost-site footer (outline @tabler/icons style,
converted to PNG since email clients — notably Outlook desktop — don't
render inline SVG). Reordered footer content: tagline, manage-notifications
link, icons as the closing element, copyright line last.
* Standardize connection-verify error classification across all 13 platforms
Every platform now follows one contract: verify() returns true on a
healthy connection, throws TokenExpiredException only on a confirmed
dead connection, and PlatformUnavailableException on anything else
(rate limit, 5xx, unrecognized). Previously most platforms silently
returned false on anything but a 401, so callers (all of which only
react via try/catch) could never distinguish "definitely dead" from
"transient" — and Telegram/Discord never threw at all.
Each platform's "is this confirmed dead" check now lives next to its
existing publish-time error classifier (App\Exceptions\Social\*PublishException)
instead of being re-typed inline in ConnectionVerifier, closing real,
already-drifted gaps between the two paths:
- TikTok and Mastodon both had a bare "status === 401/403" check shared
between publish and verify, but TikTok's scope_not_authorized and
Mastodon's write-scope 403 use the same status for a non-fatal scope
gap, not a dead token — verify's lower-privilege endpoint keeps its
own stricter check on top instead.
- Telegram/Discord authenticate with one bot token shared across every
connected account; a 401 means that shared token is misconfigured
(an operator problem), never that one specific account is broken —
excluded from both platforms' confirmed-dead checks accordingly.
- Facebook/InstagramFacebook/Mastodon/Telegram/Discord have no
per-account refresh flow at all, so a confirmed rejection now skips
the pointless refresh-and-retry (Platform::hasTokenRefreshFlow()).
Also fixes two bugs found while hardening VerifyUpcomingPostConnections:
a post hard-deleted mid-run could crash the whole job for every other
account in the batch (now filtered per group), and two overlapping runs
of the same job could send duplicate PostAtRisk warnings (now a
conditional claim on connection_warning_sent_at).
* Skip paused accounts in upcoming-post connection checks, close claim race
A paused (is_active=false) social account already fails at publish time
before any platform API call, so it shouldn't trigger a proactive
connection check or "reconnect" warning. Guard added at dispatch time
(CheckUpcomingPostConnections) and re-checked fresh mid-run inside
VerifyUpcomingPostConnections's per-account loop, since the job can take
real wall-clock time working through a workspace and an account can be
paused or deleted after the query-time guard already ran.
Also wraps the connection_warning_sent_at claim in a SELECT ... FOR UPDATE
transaction (ordered by id, 3 retries) to close a race between two
overlapping runs of the same job double-claiming and double-emailing about
the same post_platform.
* Clarify "commit" wording in claim-transaction comment
Reads ambiguously as a git commit on a PR diff; it means the DB
transaction commit.
* 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>
* fix: give Pinterest video processing more time and retry on timeout
A valid ~54s video pin failed after ~90s of polling while Pinterest was
still processing. Extend the poll window to ~5 minutes and treat timeout
as platform unavailable so PublishToSocialPlatform reschedules instead of
failing the post on the first attempt.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: use Laravel Sleep for Pinterest media processing polls
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: inline Pinterest video processing poll constants
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: map Pinterest media upload statuses to an enum
Use the official MediaUploadStatus values (registered, processing,
succeeded, failed) instead of comparing raw strings in the publisher.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: trim Pinterest media processing docblock
* fix: cap platform-unavailable retries and recover stuck retrying posts
Stop infinite reschedules after 6 attempts with a user-safe failure
message, keep technical detail in error_context, recover Retrying
platforms in social:recover-stuck-posts, and drop unused isTerminal().
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: remove unused failedCount in RecoverStuckPosts
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: skip final Pinterest poll sleep and localize recover timeout
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: raise publish job timeout headroom and ignore already-failed platforms
Give social publish jobs 15 minutes so Pinterest media polling fits under
the worker limit, bump Horizon/redis retry_after above that timeout, and
skip handle/failed when the platform is already Failed so delayed jobs
cannot revive posts recovered by social:recover-stuck-posts.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: restore social-publishing and ai-assistant horizon supervisors
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: harden Pinterest 401 handling, unique publish jobs, and recover JSON
Treat media-status 401 as TokenExpired, make PublishToSocialPlatform
unique per platform+attempt so retries still queue, and persist recover
error_context via Eloquent casts instead of manual json_encode.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: mass-update stuck post platforms without per-row each
Eloquent query updates already bind JSON arrays correctly here, so one
UPDATE is enough — no manual json_encode and no N model writes.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: simplify Pinterest media processing poll loop
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: simplify publish job retry and terminal status checks
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: do not finalize posts while platforms are still retrying
Co-authored-by: Cursor <cursoragent@cursor.com>
* test: cover Pinterest timeout, unique jobs, and recover edge cases
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: retry Pinterest media poll on connection errors and tighten tests
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: remove ineffective TypeError import that breaks CI
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Use GET /2/media/upload?media_id=&command=STATUS per X API docs, and persist
redacted raw_response on SocialPublishException failures for supportability.
Co-authored-by: Cursor <cursoragent@cursor.com>
TokenRefreshClient classified every non-5xx/429 refresh failure as a dead
token. Meta returns rate-limit (code 4/17) and transient (code 1/2) errors as
HTTP 4xx with type OAuthException, so a throttled proactive refresh was
disconnecting still-valid Instagram/Threads tokens — and the wider 24h/15-min
refresh cadence raised the odds of hitting it.
Classification now keys on error code 190 (the signal the publish exceptions
already use): only a genuine 190 disconnects; every other Meta 4xx is treated as
transient (PlatformUnavailable) and retried next cycle. The same over-broad
type-based check in verifyInstagram/verifyFacebook/verifyThreads is replaced
with the shared Meta\GraphError helper so verify and refresh agree.
The extend-while-valid path is shared by Instagram and Threads via
Platform::extendsAccessTokenOnRefresh(); add the Threads sibling of the
Instagram job test so both extension-model platforms are locked down.
Refs #126
Cold review caught a regression from the two previous commits. Instagram and
Threads use long-lived tokens refreshed by EXTENDING the access_token itself
(grant_type=ig_refresh_token / th_refresh_token) — they have no separate
refresh_token and CANNOT be refreshed once expired. The anti-over-rotation rule
("only refresh a token once it's actually expired") is right for rotating
single-use refresh_token platforms but wrong for these: it left IG/Threads
tokens to lapse, after which the extend call fails and the account disconnects
(~every 60 days).
Gate the anti-rotation on the platform's refresh model:
- Platform::extendsAccessTokenOnRefresh() — true for Instagram/Threads.
- SocialAccount::needsProactiveTokenRefresh() — expired for rotating platforms,
OR expiring-soon for extension platforms (restores isTokenExpiringSoon).
- RefreshSocialToken extends (refreshToken) extension-model tokens while still
valid, and verifies (access-token-first) rotating ones.
- All 23 publisher/analytics pre-checks now use needsProactiveTokenRefresh().
Tests: proactive job extends a still-valid Instagram token; a model test covers
the rotating-vs-extension branching; existing X/LinkedIn anti-rotation tests
are unchanged.
Refs #126
X OAuth2 refresh tokens are single-use: each refresh rotates the pair and
invalidates the previous refresh_token, and reusing a rotated one kills the
whole family. Three things made this fragile and disconnected accounts far
more often than necessary:
- The proactive refresh job called refreshToken() directly, bypassing the
access-token-first guard in verify() and rotating on every run.
- RefreshExpiringTokens used a 2h window on an hourly schedule — equal to the
2h access-token lifetime — so every X account was rotated every hour even
while its token was still valid.
- A single 4xx refresh failure disconnected the account without checking
whether a concurrent refresh had already persisted a working token.
Changes:
- RefreshSocialToken now routes through verify() (access-token-first), so it
only rotates when the access_token is actually invalid.
- Shrink the proactive window to 30m and run the command every 15m, so the
window still covers the run interval but rotation happens near real expiry.
- verify() tolerates the lost-rotation race: on a 4xx refresh, reload and
verify with a concurrently-refreshed token before marking TokenExpired.
Refs #126
Cold-review follow-ups on the PR:
- Trim the oversized docblocks/inline comments added across the API controller,
MediaAttacher, Post, the publish job, and the X publisher to one line (keeping
the @param/@return array-shape annotations).
- XPublisher::chunkedUpload now accepts ?string $mediaCategory and only sends
media_category when present — getMediaCategory() can return null, so the strict
string param was a latent TypeError (unreachable on X today, removed anyway).
- Fix MediaAttacher docblocks: the file imports Type as MediaType, so the
@param array<Type> annotations didn't resolve — now array<MediaType>.
- Tests: cover the failed() job hook genericizing a raw error, and X failing
cleanly (XPublishException) when media can't be downloaded.
The post-failure email renders each platform's stored error_message verbatim.
On an unexpected publish error the job's catch-all (and the job-failed hook)
stored the raw exception message — for a PHP TypeError that includes the server
file path (.../releases/<id>/app/Services/Social/XPublisher.php on line 130),
which then reached the customer's inbox.
Only our own SocialPublishException carries a vetted, user-facing message.
Every other throwable (engine errors like TypeError, or library exceptions such
as Intervention's decoder errors which embed temp paths) is now replaced with a
generic line; the raw detail stays in the logs. Token-expired and rescheduled
paths are unchanged.
Note: publishers that still throw plain \Exception for user-facing reasons
(some X/Facebook/Threads/LinkedIn cases) will now show the generic message for
those; converting them to typed SocialPublishExceptions to restore specific
copy is a worthwhile follow-up.
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 PublishToSocialPlatform test that runs the real job -> publisher chain (no publisher mock) for a linkedin_document post, asserting the documents API flow and the published platform state
- resolveDocumentTitle selects the document item explicitly (first(isDocument)) to match publishDocument, instead of the first media item
All customers were migrated to the single Workspace plan and the old plan rows
were deleted in production, so drop the now-dead legacy tiers from the code:
reduce the Plan Slug enum to Workspace only and default the PlanFactory to it.
Rework StripeEventListenerTest around the single Workspace plan (its monthly and
yearly price ids still exercise plan-by-price mapping, trial clearing, deletion,
and previous-plan propagation), and point the remaining tests that referenced
the starter/pro slugs at the seeded Workspace plan.
Send the user's persona on both the server-side subscription.created event and
the client-side checkout.completed/dataLayer purchase event, for ICP analysis.
Hold the processing screen ~5s before redirecting so PostHog and the ad pixels
(Google/Meta via GTM) reliably flush. Sharpen the annual-upgrade banner copy
(lead with '2 months free') and give its check icon a white tile.
Use Account::postsCountCacheKey everywhere, avoid findOrFail when syncing
post deletes, dispatch SyncAccountUsage after DeleteWorkspace when enabled,
and add coverage for the new paths.
Co-authored-by: Cursor <cursoragent@cursor.com>
Audit found three behaviors with no explicit assertion:
- Job is dispatched with a 10-minute delay (would silently regress if
the duration changed). Uses Carbon::setTestNow + Bus::assertDispatched
inspecting \$job->delay.
- error_context.last_attempt_at is recorded at the moment of failure.
- updatePostStatus does NOT finalize the parent Post while any of its
platforms is in Retrying — covers the central invariant of the
feature (the post must stay Publishing until every platform lands
in Published or Failed).
- A platform currently in Retrying transitions to Published when the
next attempt succeeds — proves the loop terminates.
Before: a scheduled post hitting a platform outage was marked Failed —
user had to manually retry. Now the job reschedules itself for 10
minutes later and the PostPlatform shows status "Retrying". Loops
indefinitely until the platform accepts the post.
- Adds PostPlatformStatus::Retrying (existing string column, no migration)
- PublishToSocialPlatform: PlatformUnavailable catch now calls
rescheduleForRetry() which (a) updates the row to Retrying with
retry_count + next_attempt_at in error_context, and (b) dispatches
itself with a 10-minute delay. updatePostStatus() naturally leaves
the parent Post in Publishing because Retrying is neither Published
nor Failed.
- Same treatment for the retry-refresh edge case (publisher throws
TokenExpired, refresh subsequently fails with PlatformUnavailable).
- i18n + frontend status config updated (en, pt-BR, es) for both
posts.status.retrying and posts.edit.status.retrying.
- Tests: 3 new tests covering the dispatched job, the edge case path,
and retry_count increment across attempts.
Edge case from the prior commits: if the publisher throws TokenExpired
(401 path), PublishToSocialPlatform attempts refreshAccountToken() to
recover. That internally goes through ConnectionVerifier::verify, which
can now raise PlatformUnavailable (5xx). The old catch (\Throwable)
swallowed it but the loop still fell through to markAsTokenExpired —
meaning a transient platform outage during a retry could still flip the
account to expired.
Adds an explicit PlatformUnavailable catch in the retry block: marks
the post failed with category platform_unavailable and breaks before
touching the account status.
The previous commits in this PR closed the loophole on the hourly /
daily token-refresh jobs. The same loophole remained on the publish
path: every per-platform publisher (LinkedIn, X, YouTube, TikTok,
Threads, Instagram, Pinterest, Bluesky and their Analytics siblings)
has its own refreshToken() called before publishing a scheduled post,
and all of those treated any non-2xx as TokenExpired — including 5xx.
Result before this commit: a Bluesky outage that coincided with a
scheduled publish would mark the account as expired and fail the post.
Changes:
- Route every refreshToken() in the 16 publisher / analytics classes
through TokenRefreshClient::for(Platform::X)->send(...).
- TokenRefreshClient now also fills platformErrorCode from the HTTP
status and pulls error_description / error.message from the JSON
body, preserving the richer info LinkedIn / X / TikTok / Pinterest /
Threads used to put on their TokenExpiredException.
- PublishToSocialPlatform catches PlatformUnavailableException
explicitly: the post is marked failed (category: platform_unavailable,
with http_status in error_context) but the account stays Connected.
No retry inside this job — the scheduler reattempts the next run.
Test added: publish flow does NOT mark account expired when the
publisher throws PlatformUnavailable. Full suite: 1569 passing.
Review follow-ups:
- verifyMastodon was the last hardcoded host left after the PR moved
LinkedIn/YouTube/Bluesky to config. Adds trypost.platforms.mastodon
.default_instance (env MASTODON_DEFAULT_INSTANCE) and reads from it.
- refreshToken() docblock now declares @throws PlatformUnavailableException
(the whole point of the PR was missing from its contract).
- Strip the new explanatory comments inside catch blocks and tests —
rationale lives in the commit / PR, not inline. The two comments
inside empty `catch (TokenExpiredException) {}` blocks stay because
there the comment is the only thing telling the reader why the
exception is swallowed.
When a provider's API was down (5xx, timeout, DNS), the hourly
RefreshSocialToken job and daily VerifyWorkspaceConnections job were
treating it as "token revoked" and emailing the user to reconnect.
Bluesky going offline triggered false-positive disconnect notifications
because Bluesky access tokens are short-lived (2h) so every hourly
refresh failed during the outage.
- New PlatformUnavailableException: API unreachable / 5xx, transient.
TokenExpiredException stays for 4xx (token is provably bad).
- New TokenRefreshClient: normalizes failure semantics for OAuth
refresh HTTP calls across all providers. Takes a Platform enum so
typos fail at compile time and the user-facing label comes from
one source.
- ConnectionVerifier: all 8 refresh*Token methods route through the
new client. Hardcoded OAuth URLs (LinkedIn, YouTube) and Bluesky's
default PDS host moved into config/trypost.php alongside the
existing per-platform entries.
- RefreshSocialToken job: PlatformUnavailableException → log warning
and stop. Do NOT markAsTokenExpired, do NOT notify the user. Next
scheduled tick retries.
- VerifyWorkspaceConnections job: PlatformUnavailableException from
the inner refresh propagates and is treated as a transient skip.
Onboarding/lifecycle workflows in PostHog (and downstream tools like SendKit)
need to segment users by how many social accounts they've connected and how
many posts they've created. The existing SyncUser job only re-emitted these
counts on signup and billing changes, so the values went stale the moment a
user did anything meaningful.
This wires up two new paths that refresh the account group automatically:
- SocialAccountObserver (#[ObservedBy] on the model) fires SyncAccountUsage
on created/deleted, covering all 14 OAuth callback paths in one hook.
- SyncUsageOnPostCreated / SyncUsageOnPostDeleted listeners (auto-discovered)
fire SyncAccountUsage on the corresponding events dispatched by CreatePost
and DeletePost.
SyncAccountUsage is the new dedicated job for group properties only
(groupIdentify account + workspace). SyncUser was slimmed to just identify
the user and delegate the group sync, removing the duplicated property
mapping between the two jobs.
All entry points (observer + both listeners) short-circuit when PostHog is
disabled, so self-hosted instances without PostHog configured see zero
queued jobs and zero overhead.
posts_count cache is invalidated before each sync so the job reads fresh
counts from the database instead of stale cached values.
Three orthogonal fixes that together close the gap where social tokens
were silently aging out without ever being refreshed, then dying at the
provider when the refresh_token also got revoked.
The original failure mode: a user's X token expired because the hourly
proactive-refresh cron's smart `verify()` skip-logic kept saying 'token
still works, no need to refresh', and once the token actually expired,
the cron's WHERE clause excluded it from future runs. By the time anyone
noticed, the refresh_token at X was also gone.
(C) ConnectionVerifier: rename private `refreshTokenIfNeeded` →
public `refreshToken`. Callers that want the smart 'try
access_token first' behavior keep using `verify()`. Callers that
want a proactive refresh (the cron) call `refreshToken` directly.
(B) RefreshExpiringTokens command: drop the
`where('token_expires_at', '>', now())` filter. Already-expired
tokens now get a last-chance refresh attempt before the
refresh_token also dies at the provider. Status filter
(`Connected`) still excludes accounts already marked TokenExpired.
(D) RefreshSocialToken job: switch from `verify()` to
`refreshToken()`, and on `TokenExpiredException` call
`markAsTokenExpired` so the user is notified immediately. The lock
+ transition detection in markAsTokenExpired prevents notification
spam if subsequent cron passes also fail.
Tests:
- 3 new tests for RefreshSocialToken (calls refreshToken not verify,
marks TokenExpired on TokenExpiredException, logs warning on other
errors)
- Updated RefreshExpiringTokens test to assert already-expired tokens
are now dispatched (was previously asserted as 'should NOT')
Three related fixes for the failure mode where a scheduled post errors out
as 'An unknown X error occurred.' when a social account's refresh_token
was already invalidated by the provider:
1. **PublishToSocialPlatform**: fail-fast when account status is
`TokenExpired`. Previously the job tried to publish, the publisher
internally tried to refresh, the provider rejected the rotated
refresh_token, and the failure surfaced as a generic 'unknown' error
instead of a clear 'reconnect your account' signal.
2. **XPublisher::refreshToken**: when the OAuth endpoint rejects the
refresh_token (typically because it was rotated/revoked at X), log the
raw response and throw `TokenExpiredException` instead of falling
through to `XPublishException::fromApiResponse` which expects the
tweet-API response shape (`type`/`title`/`detail`) and treats
OAuth-style responses (`error`/`error_description`) as 'Unknown'.
3. **SocialAccount::markAsTokenExpired**: dispatch an in-app + email
notification (`Type::AccountDisconnected`) when an account
transitions from `Connected` → `TokenExpired`, mirroring the
existing pattern in `markAsDisconnected`. Wrapped in a lock to
prevent duplicate notifications on concurrent transitions. Accepts an
optional `notify: false` so the batch verifier
(`VerifyWorkspaceConnections`) can suppress per-account
notifications and rely on its summary email.
Three fixes from a fresh code review:
1. SyncUser identify used 'email' / 'name' instead of the PostHog
special person properties '\$email' / '\$name'. The frontend already
used the correct keys; the backend identify (sole source for users
who sign up but never log in) would have populated only custom
properties, leaving the built-in person profile email/name blank
in the PostHog UI.
2. handleSubscriptionDeleted now short-circuits when plan_id is already
null. Stripe re-delivers webhooks on transient failures, and the
prior version would dispatch a duplicate 'subscription.cancelled'
event and re-flush the (already empty) Pennant cache on each retry.
3. useTracking composable called posthog.capture directly, bypassing
the new enabled gate. While posthog-js queues calls before init
(so no events leaked over the network in self-hosted mode), the
buffer grew unbounded and would fire all queued events in bulk if
init was ever called. Replaced with a gated captureEvent helper
exported from posthog.ts.
Plus: drop the now-trivial 'updating non-plan fields does not flush
the pennant cache' test (no observer to test against), refresh stale
doc comments referencing the removed SyncUserToPostHog filename, and
add a Bus::assertNotDispatched check to the deletion-idempotency test.
Self-hosted installs that inherited POSTHOG_API_KEY from an example or
older deploy were still seeing SyncUser/SendEvent jobs run because the
gate was based on the api key alone. Switches the gate to an explicit
'services.posthog.enabled' flag (env: POSTHOG_ENABLED, default false)
and requires both enabled=true AND api_key for tracking to fire.
Backend gating:
- PostHogService::isEnabled() — single static helper used everywhere.
- AppServiceProvider::configurePostHog — skips PostHog::init when off.
- CreateUser::execute — does not enqueue SyncUser when off.
- SyncUser::handle, TrackBilling::handle, SendEvent::handle — early
return before any DB query so the queue worker does no work.
Frontend gating:
- New VITE_POSTHOG_ENABLED env var mirrored from POSTHOG_ENABLED.
- initializePostHog, syncPostHogContext, capturePageview all gated.
Tests updated to set both flags on the happy path; adds explicit
'CreateUser does not dispatch SyncUser when PostHog is disabled'.
Deploy note: the trypost.it cloud .env must set POSTHOG_ENABLED=true
before this branch is merged or analytics will go dark.
- New BillingEvent enum replaces 'subscription.{created,updated,cancelled}'
strings across StripeEventListener, TrackBilling and tests.
- SendEvent now takes (method, payload) directly instead of an array of
single-call shapes — overhead with no batching benefit.
- PostHogService consolidates the 3 api-key short-circuits into shouldSend().
- SyncUser eager-loads currentWorkspace.withCount('socialAccounts') and
drops the redundant posts_count from the workspace group identify.
- Frontend Usage interface centralised in resources/js/types — was
duplicated in posthog.ts and useFeatureAccess.ts.
- posthog.init moved out of module-import side-effect into
initializePostHog() called explicitly from app.ts.
- SyncUserTest cleans up the convoluted assertion that merged
$job->calls with Queue::pushed().
- Drop tests/Feature/StripeEventListenerTest.php (orphan, fully covered
by tests/Feature/Listeners/StripeEventListenerTest.php).
- Revert .github/FUNDING.yml to match origin/main.
The previous version of `handleSubscriptionUpdated` and
`handleSubscriptionDeleted` were no-ops, so a plan swap or cancellation
on Stripe never reflected in `accounts.plan_id`. Mirrors sendkit's
listener structure and adds full coverage.
Listener:
- `handleSubscriptionCreated` / `handleSubscriptionUpdated` capture the
previous plan name, resolve the new plan from the subscription items'
price ids, and update `account.plan_id` if it changed. Pennant
feature caches are forgotten automatically by `Account::booted()`.
- `handleSubscriptionDeleted` clears `account.plan_id` so the UI and
authorization checks reflect "no plan" instead of keeping the
previous one attached. Idempotent.
- Helpers extracted: `resolvePlanFromSubscriptionItems(payload, account)`
(pure resolver, logs a warning when no plan matches) and
`trackPlanChange(account, event, previousPlan, payload)` (delegates to
the queued `TrackBilling` job).
- Restored the `match($type) => handler...` shape with explicit
protected handler methods for each subscription type.
TrackBilling:
- Adds optional `?string $previousPlan` constructor param. Forwarded as
`previous_plan` event property so PostHog funnels can tell upgrade
from downgrade from cancellation.
Tests:
- New: subscription updated/created sync `plan_id` from price ids
(monthly + yearly), idempotent when price already matches, ignored
when price ids are unknown, deletion clears plan_id, deletion
idempotent when already null. Tests override the seeded plans'
Stripe price ids inline so they don't depend on `.env.testing`
having `STRIPE_*_MONTHLY/YEARLY` set.
- New: previousPlan is forwarded to `TrackBilling` for updated and
deleted, and is null for first-activation.
- TrackBilling test: covers the previous_plan property in capture
payload (both supplied and default-null cases).
Suite: 1438 passed (+11).
Plus a tiny cleanup: replaced the `'PostHog\\SendEvent: ...'`
namespace-look-alike in the SendEvent log warning with the cleaner
`'PostHog SendEvent: ...'`.
Reorganises PostHog plumbing under `App\Jobs\PostHog` and extracts the
Stripe billing capture out of `StripeEventListener` into its own job.
Adds the missing test coverage that was promised but not delivered in
the previous commit.
Code changes:
- Move `app/Jobs/SendPostHogEvent.php` → `app/Jobs/PostHog/SendEvent.php`
(low-level dispatcher).
- Move `app/Jobs/SyncUserToPostHog.php` → `app/Jobs/PostHog/SyncUser.php`
(high-level user/account/workspace sync).
- New `app/Jobs/PostHog/TrackBilling.php` that owns the
capture('subscription.*') + SyncUser re-dispatch flow. Receives
account id + event name + payload, runs on the `posthog` queue.
- `StripeEventListener` slims down to a switch table mapping Stripe
event types to PostHog event names and dispatches `TrackBilling`. No
more inline tracking logic in the listener.
- `resources/js/posthog.ts` now owns `syncPostHogContext(page)` and
`capturePageview()`. `resources/js/app.ts` imports them — no behaviour
inlined in the bootstrap.
- `app/Services/PostHogService.php` and `app/Actions/User/CreateUser.php`
updated to the new namespaces.
Tests added/updated:
- `tests/Feature/Jobs/PostHog/SyncUserTest.php` — identify/group payload
shape, account metrics, workspace skip when none, queue assignment,
no-op without api key.
- `tests/Feature/Jobs/PostHog/TrackBillingTest.php` — capture payload,
SyncUser re-dispatch, missing-account/owner handling, api key gate.
- `tests/Feature/Jobs/PostHog/SendEventTest.php` — moved from
`tests/Feature/SendPostHogEventTest.php` and updated to new namespace.
- `tests/Unit/PostHogServiceTest.php` — adds coverage for the
account-aware capture (auto-attached `\$groups.account`, `account_id`,
`plan`) and the no-account branch.
- `tests/Feature/Listeners/StripeEventListenerTest.php` — replaces the
old inline-PostHog assertions with `Bus::fake([TrackBilling::class])`
and verifies the listener dispatches TrackBilling with the right
account id + event name for each subscription type, and skips
non-subscription event types.
- `tests/Feature/Actions/User/CreateUserTest.php` — verifies signup
dispatches `SyncUser` with the new user id.
Suite: 1427 passed (+20 net new, including the previous round of
metrics-related tests).
- Create Account model as Cashier Billable entity (stripe, plan, subscription)
- Account owns workspaces and has an owner_id (User)
- User belongs to one Account via account_id
- Workspace belongs to Account via account_id, no longer has billing fields
- Remove Brand model entirely (workspaces serve as grouping)
- Rename brand_limit to workspace_limit in plans
- Workspace roles simplified: admin/member/viewer (owner via Account)
- Invites now belong to Account with workspaces JSON array
- Pennant features scope changed from Workspace to Account
- EnsureSubscribed middleware checks Account subscription
- All controllers updated: BillingController, OnboardingController,
WorkspaceInviteController, SocialController, StripeEventListener
- Frontend: extract GoogleAuthButton component, create WorkspaceRole
enum for type-safe role checks, fix all views for new architecture
- All 1101 tests passing
- Fix UpdatePostRequest missing content_type, synced, meta fields
(content_type was silently dropped, causing Instagram Reels to post as Feed)
- Create API FormRequests (StorePostRequest, UpdatePostRequest) replacing inline validation
- Fix syntax errors in all publishers ($media->isVideo() missing variable)
- Fix Instagram Feed with single video calling publishSingleImage instead of publishReel
- Fix TikTok hardcoded SELF_ONLY privacy — now queries creator_info API
- Refactor YouTubePublisher to use google/apiclient SDK with chunked resumable upload
- Fix all publishers using file_get_contents for large videos (memory overflow)
— X, LinkedIn, LinkedInPage, Pinterest, Bluesky, Mastodon now use temp file + stream
- Fix Media::isVideo/isImage to use mime_type instead of extension
- Fix Threads not saving refresh_token (was null, now saves access_token)
- Add Instagram token refresh to publisher and ConnectionVerifier
- Fix PublishToSocialPlatform job: tries 3→1 (prevents duplicate uploads),
timeout 60→600s, added failed() method for cleanup
- Increase Horizon worker timeout 60→630s, Redis retry_after 90→660s
- Increase upload limit 500MB→1GB
- Add mastodon to getDefaultContentType in Edit.vue
- Refactor WorkspacePolicy to use pivot role instead of workspace.user_id
- Add manageBilling policy (owner only) to BillingController
- Fix ApiKeyController authorization (view → manageTeam for store/destroy)
- Fix WorkspaceInviteController using workspace.user_id for owner checks
- Fix WorkspaceController settings is_owner using workspace.user_id
- Create PostAction enum for UpdatePost/PostController action strings
- Create ApiToken\Status enum
- Add User::SUBSCRIPTION_NAME constant, replace all hardcoded 'default'
- Convert wantsEmailFor to accept NotificationType enum
- Convert all $data[] to data_get() across publishers, controllers, jobs
- Fix SocialLoginController callback missing try/catch
- Fix SocialController::toggleActive missing workspace null check
- Fix UpdatePost NPE on meta merge when postPlatform not found
- Remove HTML5 required attributes from form inputs
- Convert function declarations to arrow functions in Vue components
- Replace hardcoded URLs with Wayfinder route helpers
- Replace new Date() with dayjs
- Add 16 new test files covering policies, authorization, publishing
Backend:
- Create notifications table (user_id, workspace_id, type, channel,
title, body, data JSON, read_at, archived_at)
- Create Notification model with Type enum (post_failed,
account_disconnected, invite_received, member_joined, member_removed)
and Channel enum (email, in_app, both)
- Create SendNotification job: isolated from publish flow, handles
saving in-app notification and sending email independently
- NotificationController: index (excludes archived, scoped to workspace),
markAsRead, markAllAsRead, archiveAll
- Integrate with PublishToSocialPlatform (post failed/partial)
- Integrate with VerifyWorkspaceConnections (batch disconnection)
- Integrate with SocialAccount::markAsDisconnected (single disconnection)
- All use SendNotification::dispatch() instead of direct Mail::to()
Frontend:
- NotificationBell component in sidebar footer with unread badge
- Dialog with notification list, mark as read, mark all read, archive all
- Click navigates to relevant page (post edit, accounts)
- i18n for notifications UI (en, es, pt-BR)
Tests:
- 8 tests for NotificationController (auth, CRUD, workspace scoping)
- 4 tests for SendNotification job (channels, email, data storage)
All 745 tests passing.