Commit graph

30 commits

Author SHA1 Message Date
Grigory Frolov
0db9e0706d
Visible Terms and Privacy links on the auth screens (#317)
* feat: visible Terms/Privacy links on the auth screens

Social-platform app reviews (TikTok explicitly) require both links to be
reachable from the public site without logging in or opening a menu.

* Cover the legal links with tests and tidy the layout

The links are a compliance artifact an outside reviewer checks, but
nothing asserted they exist. A refactor of this layout could drop the
footer silently and the next platform submission would fail the same
check that prompted the PR. A feature test asserts the shared prop
reaches both guest screens and carries whatever the install configured,
and a browser test asserts the two links actually render to a logged-out
visitor.

Declares legal on SharedData, so the props read through the interface
rather than its index signature and the inline cast goes away.

Adds rel="noopener noreferrer" to both anchors, matching every other
target="_blank" in the codebase, and orders the imports the way eslint
expects so the file lands clean rather than relying on --fix in CI.

Drops the comment explaining why the links are there: that rationale
belongs in the commit and the pull request, and the sibling comments in
this file describe markup rather than justify decisions.

* Reuse the legal sentence the register screen already had

The register screen has shown "By continuing, you agree to our Terms of
Service and Privacy Policy" in production for a long time, translated
into all sixteen locales. Only the login screen was missing it, and the
two URLs were hardcoded inside the translated string, so a self-hosted
install could not point them at its own documents.

So this keeps what already worked and changes only those two things. The
markup moves into one component, which the login screen now renders as
well. The translated sentence keeps its wording and its link labels; only
the href becomes an i18n placeholder that the component fills from
config. That is one line per locale, and no new translation keys.

Reverting the footer out of AuthSplitLayout also stops the links from
appearing on the workspace index and create screens, which reach that
layout too and are seen after login rather than before it.

The sentence no longer hides on a self-hosted install. It was hidden
because it named TryPost's own documents; now that the URLs are
configuration, an install that sets them wants it shown.

A feature test covers the shared prop on both screens and the placeholder
in every locale; a browser test covers the rendered sentence, since the
links are a compliance artifact an outside reviewer checks and nothing
guarded them.

* Let the browser assertions do their own waiting

The test hand-rolled a polling loop in injected JavaScript to wait for
the element to mount, because the project notes say browser assertions do
not wait for SPA paint.

They do. visit() returns a PendingAwaitablePage backed by
AwaitableWebpage, whose __call wraps every method in
Execution::waitForExpectation and retries until the Playwright timeout,
which defaults to five seconds. The plugin even deprecates waitForText in
favour of assertSee for this reason. The loop was re-implementing the
retry that already surrounded each call, less well and with a helper
whose name has to be unique across the whole suite because these are
global functions.

Halves the file and drops the injected script. assertSeeLink also says
more than the old check did: it asserts the labels are links, not just
text that happens to appear.

---------

Co-authored-by: Paulo Castellano <paulo@castellanos.llc>
2026-09-01 18:20:38 -03:00
Hafiz Muhammad Moaz
91c3d86d86
Allow multiple social accounts per network via env (#286)
* 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>
2026-08-25 07:28:14 -03:00
Paulo Castellano
de54ea24f9
feat: fire signup/checkout PostHog events from the backend (#277)
* 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.
2026-08-12 11:47:47 -03:00
Paulo Castellano
ca1e346227
feat: capture ad click IDs for ad-platform conversion attribution (#276)
* 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.
2026-08-11 15:07:23 -03:00
Paulo Castellano
a147c7414b
feat: proactive connection check for at-risk posts + SocialAccount name centralization (#256)
* 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.
2026-08-09 11:10:39 -03:00
Paulo Castellano
53a5a8bf22
Allow account owners to delete workspaces (#208)
* Allow owners and admins to delete workspaces from settings.

Expose a danger zone with name confirmation, sync Stripe quantity on SaaS, and skip billing constraints in self-hosted mode.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop redundant canDelete prop from workspace settings.

The settings page is already gated by update (owner/admin), which matches delete.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Extract workspace delete danger zone into DeleteWorkspace component.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Clarify workspace delete billing copy across locales.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Match workspace delete card to the delete-account settings pattern.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Harden workspace and account deletion around shared members.

Enforce owner-only workspace creation, rehome stranded members to a personal account, warn about member access loss, and clarify the only-workspace SaaS exit paths.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Harden workspace delete: owner-only billing impact and safer member rehome.

Restrict delete to account owners, rehome stranded members transactionally with account-scoped fallbacks, and clean up the danger-zone UI/copy.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix workspace delete review findings.

Prune pending invites and media on delete, lock the account for the
last-workspace guard, fall back to account-owned workspaces for owners,
redirect self-hosted last deletes to create, cancel Stripe after local
cleanup, align personal-account trials, and gate Index create for owners.

Co-authored-by: Paulo Castellano <hello@paulocastellano.com>

* Harden invite accept and account delete edge cases.

Stop invite accept from demoting existing roles, expire dead invites on
show, preserve flash by avoiding calendar bounces, move media file I/O
outside locked delete transactions, and finish account deletion even if
Stripe cancel fails.

Co-authored-by: Paulo Castellano <hello@paulocastellano.com>

* Fix remaining invite redirect and media cleanup edge cases.

Distinguish already-accepted invites from gone workspaces, rehome
members removed from their last shared workspace, capture media paths
inside the delete lock, extract orphaned-file cleanup, and use Wayfinder
for the expired-invite home link.

Co-authored-by: Paulo Castellano <hello@paulocastellano.com>

* Fix invite current-workspace and account-delete edge cases.

Switch invitees onto an invite-account workspace when accepting, prefer
same-account fallbacks when removing members, abort account deletion if
Stripe cancel fails, and clear avatar media on profile delete.

Co-authored-by: Paulo Castellano <hello@paulocastellano.com>

* Fix Stripe-failure media leak and invite cross-account redirect.

Flush workspace media files before billing cancel can abort account
delete, and rehome stranded non-owners before picking an invite redirect
fallback so current workspace never points across accounts.

Co-authored-by: Paulo Castellano <hello@paulocastellano.com>

* Never set cross-account current workspace on member rehome.

Keep RemoveMember and account-delete member fallbacks same-account
only, clarify the billing-failure flash that workspaces were already
removed, and assert storage deletion in media cleanup tests.

Co-authored-by: Paulo Castellano <hello@paulocastellano.com>

* Sync Stripe workspace quantity when account delete billing fails.

After local workspaces are wiped, a stuck cancelNow must still drop
seat quantity so the subscription cannot keep billing the old count.

Co-authored-by: Paulo Castellano <hello@paulocastellano.com>

* Prune account invites when owner delete wipes workspaces.

Pending and accepted invites are removed with the workspaces so a
Stripe cancel failure cannot leave unique email/account rows that block
re-invites to a gutted account.

Co-authored-by: Paulo Castellano <hello@paulocastellano.com>

* Extract DeleteWorkspaceMedia to purge workspace media rows.

Call sites capture returned paths inside the lock and still flush
orphaned storage files after commit via DeleteOrphanedMediaFiles.

Co-authored-by: Paulo Castellano <hello@paulocastellano.com>

* Redirect to calendar after deleting a workspace with a fallback.

When DeleteWorkspace already sets another current workspace, sending
the owner to the workspace picker is unnecessary — take them back into
the app instead.

Co-authored-by: Paulo Castellano <hello@paulocastellano.com>

* Use Wayfinder for invite redirect and logo home links.

Replace hardcoded /invites/{id} and / hrefs in AcceptInvite with
show.url() and home() route helpers.

Co-authored-by: Paulo Castellano <hello@paulocastellano.com>

* Use Wayfinder home() for AcceptInvite logo link.

Co-authored-by: Paulo Castellano <hello@paulocastellano.com>

* Extract AcceptInvite title and description into computeds.

Keeps the expired/active copy logic out of the template and matches
the existing trans() pattern used elsewhere.

Co-authored-by: Paulo Castellano <hello@paulocastellano.com>

* Fix lazy-loading crash when deleting a workspace.

isAccountOwner() no longer touches the account relation unless it is
already loaded, and delete/rehome queries eager-load account when they
need ownership checks under Model::shouldBeStrict().

Co-authored-by: Paulo Castellano <hello@paulocastellano.com>

* Avoid isAccountOwner during workspace delete fallback.

Compare against the already-loaded account owner_id so current-workspace
reassignment cannot touch the account relation under shouldBeStrict().

Co-authored-by: Paulo Castellano <hello@paulocastellano.com>

* Add tests for DeleteWorkspace functionality

Introduce comprehensive tests for the DeleteWorkspace action, covering scenarios such as deleting stranded members, handling multiple workspaces, restoring members with personal workspaces, and managing invites. Ensure that workspace media files are deleted and verify behavior when the last workspace is blocked by SaaS settings. This enhances the reliability of workspace deletion processes and ensures proper account management during deletions.

* Refactor member removal process to delete or restore stranded members

Updated the RemoveMember action to utilize the new DeleteOrRestoreStrandedMember class, which handles the deletion of stranded members or restoration to personal accounts. This change improves the management of user accounts when members are removed from workspaces, ensuring that non-owner members are properly handled based on their account status. Additionally, tests have been updated to reflect these changes, ensuring that the functionality works as intended.

* Enhance member removal and media management during account deletion

Updated the RemoveMember action to collect media paths for orphaned files when removing members. Integrated the DeleteOrphanedMediaFiles action to ensure that any media associated with deleted users is properly purged. Additionally, refactored the DeleteOrRestoreStrandedMember class to return media paths for cleanup, improving overall resource management during user account deletions. This change ensures that all orphaned media files are handled efficiently, maintaining system integrity.

* Enhance user account deletion process with force delete option

Updated the DeleteOrRestoreStrandedMember class to include a forceDelete parameter, allowing for immediate deletion of members and their associated personal accounts and workspaces. This change ensures that when an account is forcefully deleted, all remnants of the user's data are purged, improving data integrity and resource management. Additionally, updated related methods and tests to accommodate this new functionality, ensuring comprehensive coverage and correct behavior during account deletions.

* Extract shared delete/invite actions out of fat controllers.

Centralize workspace/account/user teardown and invite accept/decline so ProfileController and AcceptInviteController stay thin HTTP wrappers.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Harden delete/invite invariants and replace invite string outcomes.

Block cross-account workspace listing/switching, cancel Stripe on owned accounts before purge, lock RemoveMember, fold owner fallback into ReassignCurrentWorkspace, and type invite results with an enum.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Polish delete/invite teardown APIs and cancel Stripe on empty accounts.

Extract DeleteEmptyOwnedAccounts, rename settle-after-invite, and expose
clearer stranded-member entry points so cancel never races the invite lock.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Finish stranded teardown craft: settle outside locks, clearer names.

Defer empty-account Stripe cancel until after the account lock, rename
stranded handling to SettleStrandedMember, and extract AccountsRequiringCancel.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Harden multi-account Stripe cancel order and typed stranded settlements.

Cancel member personals before the shared account, introduce CancelAccounts
and StrandedSettlement::flush so partial Stripe failures leave billing intact.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Reuse strandedMemberOnSharedAccount across delete/invite feature tests.

Expand the Pest helper for shared workspaces and owner injection so
stranded-member fixtures stop being hand-rolled in every suite.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Lock the account row during owner account teardown.

Serialize DeleteAccount with DeleteWorkspace/RemoveMember so concurrent
stranded restores cannot move members off the account before force-delete.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop personal-account restore when leaving a shared account.

Invitees abandon their previous personal account on accept, and stranded
members are always deleted — matching the real product flow.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Close the account model and consolidate teardown actions.

Block invites to emails that already belong to a registered user — accounts
are closed (one user, one account), so members never own a personal account.
This removes the whole leftover/restore surface.

Consolidate: fold AccountsRequiringCancel/CancelAccounts into
CancelAccountSubscription, drop DeleteEmptyOwnedAccounts/DeleteOwnedAccount/
PurgeOwnedAccounts, and fold DeleteAccount into DeleteUser. 23 -> 15 new
action files.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Remove orphaned members.errors.already_member translation key.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Block invitees from creating a workspace on the invite shell.

A pending invitee could open workspaces/create (outside EnsureHasWorkspace)
and add a workspace (then billing) on their empty signup shell before accept.
Accept only tears down an empty shell, so this left an abandoned, billable
account. Deny create/store while an invite is pending — the invitee joins via
the invite instead.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Tighten stranded-member fixtures to the closed-account model.

Drop the member's empty signup shell in strandedMemberOnSharedAccount and the
billing-abort profile test so the setup matches what accept actually leaves
(member owns nothing). Remove the never-overridden attachOwner param.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Bind invite registration to the invited email.

The register form shows the invited email as read-only when an invite id is
present, and store() rejects a different email for a valid invite. Also fixes
a latent bug: EnsureRegistrationEnabled only read the invite id from the query
string, so the self-hosted invite registration POST always 404'd.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Move register validation into RegisterRequest.

Inline $request->validate() and the invite-email check move into
App\Http\Requests\App\Auth\RegisterRequest (withValidator). Invite detection
no longer sniffs a /invites/ redirect string — it resolves the invite id
directly; the invite registration test now uses a real invite.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 13:00:38 -04:00
Paulo Castellano
3e15144399 chore: remove unused Pennant package and dead user-timezone handling
Pennant: all feature flags were replaced by BillingCycle, so remove the package
(composer), the now-empty app/Features discovery, the pennant-development skill,
and replace the create_features_table migration with a drop_features_table.

Timezone: the registration flow collected a user timezone (seeder, request
validation, hidden field) but no timezone column ever existed and CreateUser
discarded it. Remove the dead handling, the orphaned Timezone rule, and the
tests that covered the now-removed validation.
2026-06-22 13:37:38 -03:00
Paulo Castellano
b7374c1adb feat(onboarding): auto-create a default workspace at signup
- CreateUser now provisions a default "{name}'s Workspace" for non-invite
  signups (email, Google, GitHub, seeder), with the owner as Admin and
  current_workspace_id set. Invite signups still skip it (they join the
  inviter's workspace on acceptance).
- UserSeeder drops its now-redundant explicit CreateWorkspace call.
- After Stripe checkout, billing/processing lands the user on the social
  accounts screen with the connect dialog open (was: calendar), so onboarding
  flows straight into connecting an account.

Quantity stays correct (1 workspace → checkout quantity 1). The first workspace
is created without brand details; brand setup remains available in Settings and
on the create-workspace screen for additional workspaces.
2026-06-22 08:18:19 -03:00
Paulo Castellano
cbf8fb283c feat: per-workspace pricing, onboarding, and billing overhaul
Pricing
- Bill per workspace ($12/mo or $120/yr each); Stripe quantity tracks the
  workspace count and syncs on workspace create/delete.
- 2,500 AI credits per workspace, pooled at the account level; monthly reset
  on the billing anniversary, annual granted upfront (no rollover).
- One social account per network per workspace; remove all count-based limits
  (workspace/social/member) and the legacy plan tiers (single Workspace plan).

Onboarding (cloud only: SELF_HOSTED=false + PostHog)
- Replace the /subscribe plan picker with /onboarding persona selection
  (Creator/Freelancer/Startup/Agency/Small business/Other), saved on the user
  (users.persona) and mirrored to PostHog, then Stripe Checkout on the monthly
  price. 8-day trial so Stripe displays 7.

Billing screen
- Remove the Change Plan dialog (dead with a single plan); add an annual-upgrade
  banner for monthly subscribers (swapToYearly).
- Current-plan card shows the workspace count instead of the plan name.

System AI
- Brand analyzer / workspace autofill is always allowed and never debits credits
  (system feature, not the user's usage).

Self-hosted (SELF_HOSTED=true) bypasses all billing, credit, limit, network,
and onboarding logic.
2026-06-21 20:40:03 -03:00
Paulo Castellano
3611e882b4 feat(billing): make trial card requirement configurable
Add a trypost config toggle to switch between card-required checkout trials and no-card signup trials, and wire signup, checkout, access gating, UI copy, and tests to both modes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 10:02:38 -03:00
Paulo Castellano
a8f999f45f chore(billing): surface trial copy and rename signup test
Show the checkout-based trial duration again on the subscribe page and rename the signup billing test file so it matches the current no-trial-at-signup behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 09:57:46 -03:00
Paulo Castellano
26c738c0ba fix(billing): require card-backed trial again
Revert the no-card signup trial flow so access depends on a Stripe subscription trial started at checkout, preventing app access before a payment method is collected.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 09:49:44 -03:00
Paulo Castellano
e4bc6f1d5c test(auth): cover remaining surfaces of the registration gate
- POST /register with ?invite query and no prior session passes the gate.
- UserSeeder seeds the admin + workspace on an empty DB and skips when a
  user already exists.
- Login page exposes selfHosted prop in both modes (proves the flag the
  frontend uses to hide the "Sign up" link reaches the client).
2026-05-19 12:03:41 -03:00
Paulo Castellano
74bd88d3d6 feat(auth): self-hosted registration gate + admin seeder (closes #46)
Self-hosted installs (SELF_HOSTED=true, the default) now close /register
to the public. Workspace invites still work — the AcceptInvite page
links into /register with ?invite={id}, the middleware persists that
into the session, and POST /register passes through.

- EnsureRegistrationEnabled middleware gates GET/POST /register.
  Accepts ?invite=… (URL) or pending_invite_id (session) as the pass.
- RegisteredUserController::store clears the marker after signup.
- AcceptInvite.vue passes invite.id in the register link's query string.
- Login.vue hides the "Sign up" link when self_hosted.
- UserSeeder bootstraps a single admin (admin@trypost.it / password).
  Idempotent; not wired into DatabaseSeeder — operator runs
  `php artisan db:seed --class=UserSeeder` per the install docs.
- Tests cover both flag values for every changed surface.

Docs PR: see trypost-docs self-hosting/installation.mdx step 3.
2026-05-19 11:45:16 -03:00
Paulo Castellano
83f9e69eed feat(billing): surface generic trial state on billing settings + 7d default
- BillingController::index reads onTrial from Account::isOnTrial() (covers
  generic trial without a Stripe subscription) and falls back to
  account.trial_ends_at when no subscription exists. Vue page already had
  the badge + 'Trial ends' UI wired — just needed the right props.
- Drop default trial_days from 8 to 7 for consistency with messaging.
2026-05-14 19:46:34 -03:00
Paulo Castellano
177e7f8681 feat(signup): no-card 7-day trial on Starter plan
New signups land on a 7-day generic trial (Cashier trial_ends_at) without
a Stripe customer or subscription. Account is on Starter plan limits during
the trial. After 7 days, EnsureAccountReady redirects to /subscribe per the
existing flow.

- CreateUser sets account.trial_ends_at and plan_id = Starter
- EnsureAccountReady allows access when subscribed() OR onGenericTrial()
- Account::isOnTrial() includes generic trial check

Existing users unaffected: paying users have a subscription;
never-paid users continue redirecting to /subscribe.
2026-05-14 19:37:46 -03:00
Paulo Castellano
e3acd1bb4b revert: keep one OAuth callback URL per provider
Drops the dedicated /settings/authentication/providers/{provider}/callback
route added in the previous refactor — registering a second callback URL
in each OAuth app is more ops cost than the trade is worth.

Back to one callback URL per provider, with a small `Auth::check()`
branch in the auth controllers' callbacks. The check is safe because
the redirects that initiate the round-trip enforce the right
middleware (signup/login is `guest`-only, connect is `auth`-only),
so the auth state at callback time matches the flow's intent.
2026-05-04 19:42:13 -03:00
Paulo Castellano
79f800f858 refactor: dedicated callback URL for connect-from-settings flow
Splits the OAuth connect flow off entirely from signup/login so each
route has a single responsibility.

- routes/auth.php returns to its original state — redirect + callback
  both back inside the `guest` group.
- routes/app.php gains a paired callback route at
  /settings/authentication/providers/{provider}/callback.
- AuthenticationController::connectProvider /
  connectProviderCallback override Socialite's redirectUrl so the
  round-trip stays on the connect-flow URL. The Auth::check() branch
  in the auth controllers is gone.

The OAuth apps in Google Cloud and GitHub Developer Settings need the
new callback URL registered alongside the existing one — documented in
.env.example.
2026-05-04 19:30:16 -03:00
Paulo Castellano
d89f6ceede fix: let authenticated users connect Google/GitHub from Settings
The Connect button on /settings/authentication pointed at the
auth.{provider}.redirect routes that live behind `guest` middleware,
so authenticated users were bounced to /app/home before reaching
Socialite. The OAuth callback also needed to handle two flows
(signup/login vs link to current user) but had no branch for the
second case — meaning a different-email GitHub account would have
been registered as a new user, logging the original session out.

Splits the flows by intent:

- New `app.authentication.connect-provider` route in the auth group,
  handled by the settings controller (where it sits next to
  disconnect-provider). Replaces the OAuth signup link as the
  Connect button's target.
- Auth callbacks moved out of the guest group (still one URL per
  provider, since OAuth apps only register one) and gain a single
  Auth::check() branch that calls connectToCurrentUser().
- connectToCurrentUser() rejects if the provider id already belongs
  to a different user; otherwise sets it on the current user and
  redirects back to settings with a flash message.
2026-05-04 19:22:09 -03:00
Paulo Castellano
2e9e0f716b feat: capture signup UTMs/IP and add GitHub OAuth login
Persists marketing attribution and registration metadata for new users
across the three signup paths (email, Google, GitHub):

- 5 utm_* columns + registration_ip on the users table
- PreservesUtmParameters trait stores incoming utm_* query params on
  the register/redirect GET, retrieves them on the POST/callback —
  surviving the OAuth round-trip via session
- request()->ip() captured at the controller layer

Adds GitHub as a second OAuth provider:

- GitHubController mirroring the Google one (now renamed from
  SocialLoginController for symmetry)
- Settings → Authentication can connect/disconnect GitHub like Google
- Single SocialLogin.vue component replaces the per-provider buttons
  on Login/Register, rendering each enabled provider plus a single
  "or continue with" divider

UserFactory gains defaults for the new nullable columns so model
strict-mode access in tests doesn't trip.
2026-05-04 18:42:25 -03:00
Paulo Castellano
b3b59b4d13 refactor: remove onboarding flow, implement brand analysis services, and replace setup middleware with account readiness checks 2026-04-16 23:05:51 -03:00
Paulo Castellano
7d95fd1efa chore: tracking, gtm and more 2026-03-30 21:32:43 -03:00
Paulo Castellano
f3f72afecd refactor: auth split layout, subscribe redesign, onboarding, i18n, cookie locale
Auth pages:
- Create AuthSplitLayout with animated feature slides (6 slides, 3 languages)
- All auth pages use split layout (form left, visual right)
- Add show/hide password toggle with tooltip on Register
- Legal footer only shown on Register via showLegal prop

Subscribe page:
- Redesign to match auth card pattern (centered, clean)
- Platform icons, feature checklist, dynamic trial days (trialDays - 1)
- Add "Switch workspace" link
- Full i18n (en, es, pt-BR)

Onboarding:
- Rename URLs: step1 -> role, step2 -> connect
- Add enforceStep() to prevent skipping/going back steps
- Redirect /onboarding to /onboarding/role
- Redesign Step2 with AuthSplitLayout and compact platform list
- 21 tests covering all step enforcement scenarios

Workspaces page:
- Redesign with AuthSplitLayout (list with avatars, current badge)

Language system:
- Move locale from DB to cookie (forever, unencrypted, session.domain)
- Create SetLocale middleware (sets cookie if missing, validates against config)
- Rename lang/pt-br to lang/pt-BR
- Add dayjs es locale

Other:
- Copy utils.ts from sendkit (formatNumber, formatMoney, copyToClipboard)
- ConfirmDeleteModal with text confirmation (sendkit pattern)
- i18n for ConfirmDeleteModal internal strings (common.php)
- EmptyState component for posts index
- Exact match for "All" posts in sidebar
- Posts breadcrumbs show current status filter
- DialogFooter buttons aligned left
- API Keys page redesign with Table, DropdownMenu, EmptyState
- Extract CreateApiKeyDialog and InviteMemberDialog to components
- Remove API Keys from sidebar
- DropdownMenuItem destructive variant for Remove action
2026-03-30 11:53:42 -03:00
Paulo Castellano
56b8c92e72 refactor: settings redesign, Spanish translations, language system, strict_types
Settings pages:
- Redesign layout to match Sendkit (max-w-4xl, space-y-12, Separator sections)
- Merge Members page into Workspace settings with Table, invite Dialog, ConfirmDeleteModal
- Add workspace logo upload/delete routes and controller methods
- Translate all hardcoded strings in Workspace.vue modals

Language system:
- Drop languages table, replace language_id FK with locale string column on users
- Create config/languages.php for available languages and default locale
- Add Spanish (es) translations (13 files)
- Simplify HandleInertiaRequests, ProfileController, RegisteredUserController

Code quality:
- Add declare(strict_types=1) to all PHP files
- Fix MastodonPublisher using wrong attribute (filename -> original_filename)
- Fix HasMediaTest for new has_photo/photo_url accessors
- Fix PublishToSocialPlatformTest type error revealed by strict_types
- Remove orphaned Language model from AppServiceProvider morph map
- Update User TypeScript interface (has_photo, photo_url, locale)
- Eager load media relation on workspaces to prevent N+1
- Add 8 new tests for workspace logo upload/delete
- Update workspace settings test to assert members/invitations props

All 710 tests passing.
2026-03-30 00:20:43 -03:00
Paulo Castellano
8689e54e55 refactor: restructure to Actions, subdomain routes (app/api), API tokens
- Extract business logic from controllers into Action classes:
  Post/, Workspace/, Hashtag/, Label/, Invite/, ApiKey/
- Create subdomain routing: app.trypost.test (Inertia dashboard),
  api.trypost.test (REST API with token auth)
- Add ApiToken model with tp_ prefix, token_lookup/hash auth
- Add AuthenticateApiToken middleware for API authentication
- Create Api controllers with JSON Resources for all entities
- Create App controllers that use Actions + Inertia responses
- Organize Form Requests into Api/ and App/ directories
- Add api_tokens migration
- Update all route names with app. prefix
- Update all tests to use new route names (684 passing)
2026-03-29 19:24:28 -03:00
Paulo Castellano
84911dcb34 fix: accept deprecated timezones during registration 2026-03-29 17:26:27 -03:00
Paulo Castellano
b03ff582c0 feat: improvements on ui 2026-01-21 18:55:33 -03:00
Paulo Castellano
1196551a26 feat: improvements on invites 2026-01-20 16:53:54 -03:00
Paulo Castellano
d538537706 feat: Implement user onboarding, subscription management, and refactor social integrations with new UI components and mail templates. 2026-01-17 12:36:49 -03:00
Paulo Castellano
6890147aa6 chore: first commit 2026-01-14 22:13:44 -03:00