2026-01-15 01:13:44 +00:00
|
|
|
<?php
|
|
|
|
|
|
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 03:20:43 +00:00
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
2026-01-15 01:13:44 +00:00
|
|
|
namespace App\Models;
|
|
|
|
|
|
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 14:47:47 +00:00
|
|
|
use App\Enums\Auth\SocialAuthProvider;
|
2026-03-31 03:40:18 +00:00
|
|
|
use App\Enums\Notification\Type as NotificationType;
|
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 23:40:03 +00:00
|
|
|
use App\Enums\User\Persona;
|
2026-07-18 19:07:11 +00:00
|
|
|
use App\Enums\User\ReferralSource;
|
Activation checklist + MCP OAuth authorize UX (#239) (#250)
* Wire onboarding activation into Account, observers, and shared Inertia data
Add onboarding casts/hasFinishedOnboarding, AccessToken ObservedBy,
Platform::connectableOptions, Post/SocialAccount onboarding broadcast hooks,
and lazy onboardingResidual share + SharedData types.
* Register onboarding routes and post-checkout activation redirects.
Wire billing processing and the sidebar checklist so owners land on
activation after subscribe, with locale sidebar/uk onboarding strings.
* Align MCP grant usability with onboarding activation checks
Unbound MCP tokens fall back to the user's current workspace and require
createPost so viewer/unscoped grants neither unlock the checklist nor
broadcast onboarding status.
* Require bound MCP workspace for onboarding activation.
Drop current-workspace fallback from usable MCP grants so checklist
detection and broadcasts match Passport token scoping; viewers still
cannot unlock the MCP step.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Harden onboarding review findings and tighten locale strings.
Fix Welcome/Persona/TrackPost suites broken by the activation route reuse
and PostObserver analytics side effects, restore Echo poll fallbacks,
reject unbound MCP grants in tests, and drop unused onboarding.mcp keys.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Remove unused sidebar and MCP authorization locale keys.
Drop dead sidebar menu/theme strings (including the overwritten
workspace label and api_keys nav entry) and unused MCP authorize
app_title/approving copy across all locales.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix SetLocale crashing on Passport Symfony OAuth responses.
OAuth errors return a raw Symfony Response without withCookie(); attach
the default locale cookie via headers so authorize no longer 500s.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Prompt OAuth guests to log in before rejecting unknown clients.
MCP Inspector often reuses a stale client_id; validateAuthorizationRequest
was returning invalid_client JSON before the login redirect. Guests now
hit /login first, then client validation runs after authentication.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Render Inertia OAuth authorize errors for browser logins.
After login, Inertia follows the intended authorize URL; raw invalid_client
JSON broke that visit. HTML/Inertia requests now get mcp/AuthorizeError
while API JSON clients still receive the OAuth error payload.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Detect Inertia OAuth error pages via Request::inertia().
Use the framework helper so post-login authorize failures keep returning
an Inertia page instead of raw OAuth JSON.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify OAuth authorize error page detection to expectsJson.
Drop the X-Inertia header sniff; browser and Inertia visits already do
not expectsJson, while API clients still receive the OAuth JSON payload.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Share MCP authorize layout and drop the error close button.
Keep authorize and authorize-error on the same centered card shell instead of the auth split layout.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify onboarding activation for reviewability and safety.
Use an exists-based MCP check, keep GETs read-only, move sync into
syncAndNotify, clear MCP skips on connect, restrict complete to owners,
and share Echo/poll via one composable.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Move MCP OAuth authorize UX out of the onboarding PR.
Keep the activation checklist focused; OAuth guest/error-page work now
lives on fix/mcp-oauth-authorize-ux.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix corrupted French MCP locale after OAuth key cleanup.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Restore MCP OAuth authorize UX onto the onboarding branch.
Keep authorize error page, guest login-before-client validation, and
SetLocale Symfony cookie fix in #250.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix OAuth prompt=none redirects and harden onboarding tests.
Keep login_required/consent_required as redirects instead of Inertia,
add regression coverage for owner-only activation, require invite email
confirmation, and align MCP connected apps with the sessions list UI.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify onboarding guards and dedupe viewed analytics.
Introduce isOnboardingOpen / belongsToAccount helpers, collapse
duplicated sync/dispatch paths, and capture onboarding.viewed once
per account.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify onboarding event, observers, and status helpers.
Tighten Account onboarding predicates, drop nullable broadcast/dispatch
APIs, and collapse repeated observer/controller guards.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Treat in-app users as always having an account.
Add resolveAccount(), tighten belongsToAccount to string ids, and fold
guest residual handling into ResolveOnboardingStatus.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Rename onboarding residual share test to progress.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify onboarding status and rename residual to progress.
Use accountOrFail, extract MCP onboarding scope, auto-leave the ready
screen, and send non-onboarding checkout back to accounts.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Extract HasAccount and prefer data_get in onboarding flows.
Move account helpers off User, drop nullable sidebarProgress, and
read OAuth/onboarding payloads with data_get.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify onboarding checks and extract HasOnboarding.
Use Eloquent + policies for MCP/backfill paths, and move account
onboarding helpers into a dedicated trait.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add trait tests and tidy onboarding imports.
Cover HasAccount and HasOnboarding under Models/Traits, prefer filled() for checkout session ids, and import Throwable instead of FQCN.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify checkout session_id and OAuth error props.
Read session_id via request->string(), and take OAuth error details from the League exception instead of decoding the response body.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify PostObserver onboarding notify path.
Share one otherPosts check for first-create and last-delete instead of separate callbacks.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Use post author as onboarding sync actor.
Drop Auth::user() preference in PostObserver; checklist sync attributes to $post->user.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify SocialAccountObserver and OAuth authorize flow.
Share create/delete onboarding notify, drop Auth actor fallback to owner, and inline Passport Inertia error handling.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Use lazy Inertia props for onboarding partial reloads.
Drop partial-header branching; wrap page props in closures and always redirect completed/dismissed accounts to the calendar.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Defer sidebar onboarding progress and stamp completion as owner-only.
Skip the MCP checklist work on full Inertia visits via deferred shared props,
early-exit token scans, and keep account completion stamps owner-gated.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify deferred onboarding progress share via canShowProgress.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add User firstName for shared auth and simplify onboarding page.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Move User firstName coverage into UserTest.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Use first_name directly without empty-name fallbacks.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Resolve onboarding sample prompt on the frontend via i18n.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Stamp onboarding completion via the account owner after teammate unlocks.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Count only the account owner MCP grant toward onboarding activation.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix OAuth consent auth-token mismatch for mid-activation owners.
Skip deferred onboardingProgress on Passport authorize so Inertia does not
rotate the session authToken, cover happy and stale-token paths in tests,
and polish MCP setup copy plus sidebar/onboarding layout.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Keep users on onboarding after activation completes.
Stamp completion and re-render the finished checklist instead of
redirecting to the calendar so owners can review the done state.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Clarify Passport consent-view opt-out and guard app-route deferral.
Rename the authorize-only route check and assert onboardingProgress still
defers on calendar, onboarding, and MCP settings.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Harden onboarding completion and MCP consent workspace binding.
Reject OAuth approve without a workspace, retry auto-complete until
stamped, send dismissed complete straight to calendar, and cover the
device consent defer opt-out.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Enable activation checklist for self-hosted installs.
Remove the self-hosted onboarding redirects, keep the SaaS-only dismiss backfill, and cover subscription-less owners plus skip/complete destinations.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add GitHub, Hacker News, and directories referral sources.
Expand the welcome referral step with open-source and directory discovery channels.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Refine welcome referral sources and labels.
Split Instagram/Threads, add Founder, and shorten Google, GitHub, AI, and blog option labels.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Sort accounts platforms alphabetically and drop connect hover plus.
Reuse connectableOptions for the accounts index and remove the unused plus badge on disconnected cards.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Centralize PostHog once-capture so disabled installs don't burn dedupe keys.
Move isEnabled + Cache::add into PostHogService::captureOnce and route onboarding viewed/step events through it.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify onboarding backfill to complete every existing open account.
Drop self-hosted and subscription filters; down clears completed_at again.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop PostHog captureOnce and use plain capture for onboarding.
Remove cache-based event dedupe; callers rely on PostHogService::capture gating.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 23:34:43 +00:00
|
|
|
use App\Models\Traits\HasAccount;
|
2026-01-17 17:44:37 +00:00
|
|
|
use App\Models\Traits\HasMedia;
|
2026-01-19 00:49:13 +00:00
|
|
|
use App\Models\Traits\HasWorkspace;
|
2026-03-29 22:24:28 +00:00
|
|
|
use Database\Factories\UserFactory;
|
2026-01-17 17:44:37 +00:00
|
|
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
2026-01-15 01:13:44 +00:00
|
|
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
|
|
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
feat: notification system with SendNotification job, dialog UI, tests
Backend:
- Create notifications table (user_id, workspace_id, type, channel,
title, body, data JSON, read_at, archived_at)
- Create Notification model with Type enum (post_failed,
account_disconnected, invite_received, member_joined, member_removed)
and Channel enum (email, in_app, both)
- Create SendNotification job: isolated from publish flow, handles
saving in-app notification and sending email independently
- NotificationController: index (excludes archived, scoped to workspace),
markAsRead, markAllAsRead, archiveAll
- Integrate with PublishToSocialPlatform (post failed/partial)
- Integrate with VerifyWorkspaceConnections (batch disconnection)
- Integrate with SocialAccount::markAsDisconnected (single disconnection)
- All use SendNotification::dispatch() instead of direct Mail::to()
Frontend:
- NotificationBell component in sidebar footer with unread badge
- Dialog with notification list, mark as read, mark all read, archive all
- Click navigates to relevant page (post edit, accounts)
- i18n for notifications UI (en, es, pt-BR)
Tests:
- 8 tests for NotificationController (auth, CRUD, workspace scoping)
- 4 tests for SendNotification job (channels, email, data storage)
All 745 tests passing.
2026-03-30 19:47:03 +00:00
|
|
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
refactor: notification preferences, header slots, calendar layout, UI polish
Notification preferences:
- Create notification_preferences table (post_published, post_failed,
account_disconnected booleans per user)
- NotificationPreferenceController with firstOrCreate on first visit
- SendNotification job respects email preferences before sending
- Settings page with toggle switches, i18n in 3 languages
- 8 new tests for preferences (controller + wantsEmailFor + job integration)
Post published notification:
- PostPublished mail + maizzle template
- Notify owner on successful publish via SendNotification job
- PostPublished type added to notification enum
Header & Layout:
- Rename AppSidebarHeader to AppHeader with left/center/right slots
- showSidebarTrigger prop to hide sidebar toggle
- Calendar: controls in header (left: nav, center: date, right: tabs + new post)
- Fixed header with scrollable content (flex h-screen pattern)
- fullWidth pages use overflow-y-auto (fixes month view scroll)
UI improvements:
- Action buttons moved to header-right: posts, hashtags, labels
- Settings breadcrumbs: "Settings > Profile" pattern
- Calendar: remove duplicate New Post button from day view
- Remove size="sm" from Schedule/Publish buttons
- Remove bg-background from header (inherits from SidebarInset)
- Add Cancel button to labels and hashtags create/edit dialogs
- Add common.cancel i18n key
- Clean up orphaned Calendar breadcrumbs
- Fix SocialAccountsGrid buttons to use shadcn Button ghost
All 753 tests passing.
2026-03-30 21:18:17 +00:00
|
|
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
2026-01-15 01:13:44 +00:00
|
|
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
|
|
|
|
use Illuminate\Notifications\Notifiable;
|
Activation checklist + MCP OAuth authorize UX (#239) (#250)
* Wire onboarding activation into Account, observers, and shared Inertia data
Add onboarding casts/hasFinishedOnboarding, AccessToken ObservedBy,
Platform::connectableOptions, Post/SocialAccount onboarding broadcast hooks,
and lazy onboardingResidual share + SharedData types.
* Register onboarding routes and post-checkout activation redirects.
Wire billing processing and the sidebar checklist so owners land on
activation after subscribe, with locale sidebar/uk onboarding strings.
* Align MCP grant usability with onboarding activation checks
Unbound MCP tokens fall back to the user's current workspace and require
createPost so viewer/unscoped grants neither unlock the checklist nor
broadcast onboarding status.
* Require bound MCP workspace for onboarding activation.
Drop current-workspace fallback from usable MCP grants so checklist
detection and broadcasts match Passport token scoping; viewers still
cannot unlock the MCP step.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Harden onboarding review findings and tighten locale strings.
Fix Welcome/Persona/TrackPost suites broken by the activation route reuse
and PostObserver analytics side effects, restore Echo poll fallbacks,
reject unbound MCP grants in tests, and drop unused onboarding.mcp keys.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Remove unused sidebar and MCP authorization locale keys.
Drop dead sidebar menu/theme strings (including the overwritten
workspace label and api_keys nav entry) and unused MCP authorize
app_title/approving copy across all locales.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix SetLocale crashing on Passport Symfony OAuth responses.
OAuth errors return a raw Symfony Response without withCookie(); attach
the default locale cookie via headers so authorize no longer 500s.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Prompt OAuth guests to log in before rejecting unknown clients.
MCP Inspector often reuses a stale client_id; validateAuthorizationRequest
was returning invalid_client JSON before the login redirect. Guests now
hit /login first, then client validation runs after authentication.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Render Inertia OAuth authorize errors for browser logins.
After login, Inertia follows the intended authorize URL; raw invalid_client
JSON broke that visit. HTML/Inertia requests now get mcp/AuthorizeError
while API JSON clients still receive the OAuth error payload.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Detect Inertia OAuth error pages via Request::inertia().
Use the framework helper so post-login authorize failures keep returning
an Inertia page instead of raw OAuth JSON.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify OAuth authorize error page detection to expectsJson.
Drop the X-Inertia header sniff; browser and Inertia visits already do
not expectsJson, while API clients still receive the OAuth JSON payload.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Share MCP authorize layout and drop the error close button.
Keep authorize and authorize-error on the same centered card shell instead of the auth split layout.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify onboarding activation for reviewability and safety.
Use an exists-based MCP check, keep GETs read-only, move sync into
syncAndNotify, clear MCP skips on connect, restrict complete to owners,
and share Echo/poll via one composable.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Move MCP OAuth authorize UX out of the onboarding PR.
Keep the activation checklist focused; OAuth guest/error-page work now
lives on fix/mcp-oauth-authorize-ux.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix corrupted French MCP locale after OAuth key cleanup.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Restore MCP OAuth authorize UX onto the onboarding branch.
Keep authorize error page, guest login-before-client validation, and
SetLocale Symfony cookie fix in #250.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix OAuth prompt=none redirects and harden onboarding tests.
Keep login_required/consent_required as redirects instead of Inertia,
add regression coverage for owner-only activation, require invite email
confirmation, and align MCP connected apps with the sessions list UI.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify onboarding guards and dedupe viewed analytics.
Introduce isOnboardingOpen / belongsToAccount helpers, collapse
duplicated sync/dispatch paths, and capture onboarding.viewed once
per account.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify onboarding event, observers, and status helpers.
Tighten Account onboarding predicates, drop nullable broadcast/dispatch
APIs, and collapse repeated observer/controller guards.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Treat in-app users as always having an account.
Add resolveAccount(), tighten belongsToAccount to string ids, and fold
guest residual handling into ResolveOnboardingStatus.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Rename onboarding residual share test to progress.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify onboarding status and rename residual to progress.
Use accountOrFail, extract MCP onboarding scope, auto-leave the ready
screen, and send non-onboarding checkout back to accounts.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Extract HasAccount and prefer data_get in onboarding flows.
Move account helpers off User, drop nullable sidebarProgress, and
read OAuth/onboarding payloads with data_get.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify onboarding checks and extract HasOnboarding.
Use Eloquent + policies for MCP/backfill paths, and move account
onboarding helpers into a dedicated trait.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add trait tests and tidy onboarding imports.
Cover HasAccount and HasOnboarding under Models/Traits, prefer filled() for checkout session ids, and import Throwable instead of FQCN.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify checkout session_id and OAuth error props.
Read session_id via request->string(), and take OAuth error details from the League exception instead of decoding the response body.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify PostObserver onboarding notify path.
Share one otherPosts check for first-create and last-delete instead of separate callbacks.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Use post author as onboarding sync actor.
Drop Auth::user() preference in PostObserver; checklist sync attributes to $post->user.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify SocialAccountObserver and OAuth authorize flow.
Share create/delete onboarding notify, drop Auth actor fallback to owner, and inline Passport Inertia error handling.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Use lazy Inertia props for onboarding partial reloads.
Drop partial-header branching; wrap page props in closures and always redirect completed/dismissed accounts to the calendar.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Defer sidebar onboarding progress and stamp completion as owner-only.
Skip the MCP checklist work on full Inertia visits via deferred shared props,
early-exit token scans, and keep account completion stamps owner-gated.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify deferred onboarding progress share via canShowProgress.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add User firstName for shared auth and simplify onboarding page.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Move User firstName coverage into UserTest.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Use first_name directly without empty-name fallbacks.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Resolve onboarding sample prompt on the frontend via i18n.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Stamp onboarding completion via the account owner after teammate unlocks.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Count only the account owner MCP grant toward onboarding activation.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix OAuth consent auth-token mismatch for mid-activation owners.
Skip deferred onboardingProgress on Passport authorize so Inertia does not
rotate the session authToken, cover happy and stale-token paths in tests,
and polish MCP setup copy plus sidebar/onboarding layout.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Keep users on onboarding after activation completes.
Stamp completion and re-render the finished checklist instead of
redirecting to the calendar so owners can review the done state.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Clarify Passport consent-view opt-out and guard app-route deferral.
Rename the authorize-only route check and assert onboardingProgress still
defers on calendar, onboarding, and MCP settings.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Harden onboarding completion and MCP consent workspace binding.
Reject OAuth approve without a workspace, retry auto-complete until
stamped, send dismissed complete straight to calendar, and cover the
device consent defer opt-out.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Enable activation checklist for self-hosted installs.
Remove the self-hosted onboarding redirects, keep the SaaS-only dismiss backfill, and cover subscription-less owners plus skip/complete destinations.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add GitHub, Hacker News, and directories referral sources.
Expand the welcome referral step with open-source and directory discovery channels.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Refine welcome referral sources and labels.
Split Instagram/Threads, add Founder, and shorten Google, GitHub, AI, and blog option labels.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Sort accounts platforms alphabetically and drop connect hover plus.
Reuse connectableOptions for the accounts index and remove the unused plus badge on disconnected cards.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Centralize PostHog once-capture so disabled installs don't burn dedupe keys.
Move isEnabled + Cache::add into PostHogService::captureOnce and route onboarding viewed/step events through it.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify onboarding backfill to complete every existing open account.
Drop self-hosted and subscription filters; down clears completed_at again.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop PostHog captureOnce and use plain capture for onboarding.
Remove cache-based event dedupe; callers rely on PostHogService::capture gating.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 23:34:43 +00:00
|
|
|
use Illuminate\Support\Str;
|
2026-05-03 21:38:17 +00:00
|
|
|
use Laravel\Passport\Contracts\OAuthenticatable;
|
|
|
|
|
use Laravel\Passport\HasApiTokens;
|
2026-01-15 01:13:44 +00:00
|
|
|
|
2026-05-03 21:38:17 +00:00
|
|
|
class User extends Authenticatable implements MustVerifyEmail, OAuthenticatable
|
2026-01-15 01:13:44 +00:00
|
|
|
{
|
2026-03-29 22:24:28 +00:00
|
|
|
/** @use HasFactory<UserFactory> */
|
Activation checklist + MCP OAuth authorize UX (#239) (#250)
* Wire onboarding activation into Account, observers, and shared Inertia data
Add onboarding casts/hasFinishedOnboarding, AccessToken ObservedBy,
Platform::connectableOptions, Post/SocialAccount onboarding broadcast hooks,
and lazy onboardingResidual share + SharedData types.
* Register onboarding routes and post-checkout activation redirects.
Wire billing processing and the sidebar checklist so owners land on
activation after subscribe, with locale sidebar/uk onboarding strings.
* Align MCP grant usability with onboarding activation checks
Unbound MCP tokens fall back to the user's current workspace and require
createPost so viewer/unscoped grants neither unlock the checklist nor
broadcast onboarding status.
* Require bound MCP workspace for onboarding activation.
Drop current-workspace fallback from usable MCP grants so checklist
detection and broadcasts match Passport token scoping; viewers still
cannot unlock the MCP step.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Harden onboarding review findings and tighten locale strings.
Fix Welcome/Persona/TrackPost suites broken by the activation route reuse
and PostObserver analytics side effects, restore Echo poll fallbacks,
reject unbound MCP grants in tests, and drop unused onboarding.mcp keys.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Remove unused sidebar and MCP authorization locale keys.
Drop dead sidebar menu/theme strings (including the overwritten
workspace label and api_keys nav entry) and unused MCP authorize
app_title/approving copy across all locales.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix SetLocale crashing on Passport Symfony OAuth responses.
OAuth errors return a raw Symfony Response without withCookie(); attach
the default locale cookie via headers so authorize no longer 500s.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Prompt OAuth guests to log in before rejecting unknown clients.
MCP Inspector often reuses a stale client_id; validateAuthorizationRequest
was returning invalid_client JSON before the login redirect. Guests now
hit /login first, then client validation runs after authentication.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Render Inertia OAuth authorize errors for browser logins.
After login, Inertia follows the intended authorize URL; raw invalid_client
JSON broke that visit. HTML/Inertia requests now get mcp/AuthorizeError
while API JSON clients still receive the OAuth error payload.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Detect Inertia OAuth error pages via Request::inertia().
Use the framework helper so post-login authorize failures keep returning
an Inertia page instead of raw OAuth JSON.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify OAuth authorize error page detection to expectsJson.
Drop the X-Inertia header sniff; browser and Inertia visits already do
not expectsJson, while API clients still receive the OAuth JSON payload.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Share MCP authorize layout and drop the error close button.
Keep authorize and authorize-error on the same centered card shell instead of the auth split layout.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify onboarding activation for reviewability and safety.
Use an exists-based MCP check, keep GETs read-only, move sync into
syncAndNotify, clear MCP skips on connect, restrict complete to owners,
and share Echo/poll via one composable.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Move MCP OAuth authorize UX out of the onboarding PR.
Keep the activation checklist focused; OAuth guest/error-page work now
lives on fix/mcp-oauth-authorize-ux.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix corrupted French MCP locale after OAuth key cleanup.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Restore MCP OAuth authorize UX onto the onboarding branch.
Keep authorize error page, guest login-before-client validation, and
SetLocale Symfony cookie fix in #250.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix OAuth prompt=none redirects and harden onboarding tests.
Keep login_required/consent_required as redirects instead of Inertia,
add regression coverage for owner-only activation, require invite email
confirmation, and align MCP connected apps with the sessions list UI.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify onboarding guards and dedupe viewed analytics.
Introduce isOnboardingOpen / belongsToAccount helpers, collapse
duplicated sync/dispatch paths, and capture onboarding.viewed once
per account.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify onboarding event, observers, and status helpers.
Tighten Account onboarding predicates, drop nullable broadcast/dispatch
APIs, and collapse repeated observer/controller guards.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Treat in-app users as always having an account.
Add resolveAccount(), tighten belongsToAccount to string ids, and fold
guest residual handling into ResolveOnboardingStatus.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Rename onboarding residual share test to progress.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify onboarding status and rename residual to progress.
Use accountOrFail, extract MCP onboarding scope, auto-leave the ready
screen, and send non-onboarding checkout back to accounts.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Extract HasAccount and prefer data_get in onboarding flows.
Move account helpers off User, drop nullable sidebarProgress, and
read OAuth/onboarding payloads with data_get.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify onboarding checks and extract HasOnboarding.
Use Eloquent + policies for MCP/backfill paths, and move account
onboarding helpers into a dedicated trait.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add trait tests and tidy onboarding imports.
Cover HasAccount and HasOnboarding under Models/Traits, prefer filled() for checkout session ids, and import Throwable instead of FQCN.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify checkout session_id and OAuth error props.
Read session_id via request->string(), and take OAuth error details from the League exception instead of decoding the response body.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify PostObserver onboarding notify path.
Share one otherPosts check for first-create and last-delete instead of separate callbacks.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Use post author as onboarding sync actor.
Drop Auth::user() preference in PostObserver; checklist sync attributes to $post->user.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify SocialAccountObserver and OAuth authorize flow.
Share create/delete onboarding notify, drop Auth actor fallback to owner, and inline Passport Inertia error handling.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Use lazy Inertia props for onboarding partial reloads.
Drop partial-header branching; wrap page props in closures and always redirect completed/dismissed accounts to the calendar.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Defer sidebar onboarding progress and stamp completion as owner-only.
Skip the MCP checklist work on full Inertia visits via deferred shared props,
early-exit token scans, and keep account completion stamps owner-gated.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify deferred onboarding progress share via canShowProgress.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add User firstName for shared auth and simplify onboarding page.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Move User firstName coverage into UserTest.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Use first_name directly without empty-name fallbacks.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Resolve onboarding sample prompt on the frontend via i18n.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Stamp onboarding completion via the account owner after teammate unlocks.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Count only the account owner MCP grant toward onboarding activation.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix OAuth consent auth-token mismatch for mid-activation owners.
Skip deferred onboardingProgress on Passport authorize so Inertia does not
rotate the session authToken, cover happy and stale-token paths in tests,
and polish MCP setup copy plus sidebar/onboarding layout.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Keep users on onboarding after activation completes.
Stamp completion and re-render the finished checklist instead of
redirecting to the calendar so owners can review the done state.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Clarify Passport consent-view opt-out and guard app-route deferral.
Rename the authorize-only route check and assert onboardingProgress still
defers on calendar, onboarding, and MCP settings.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Harden onboarding completion and MCP consent workspace binding.
Reject OAuth approve without a workspace, retry auto-complete until
stamped, send dismissed complete straight to calendar, and cover the
device consent defer opt-out.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Enable activation checklist for self-hosted installs.
Remove the self-hosted onboarding redirects, keep the SaaS-only dismiss backfill, and cover subscription-less owners plus skip/complete destinations.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add GitHub, Hacker News, and directories referral sources.
Expand the welcome referral step with open-source and directory discovery channels.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Refine welcome referral sources and labels.
Split Instagram/Threads, add Founder, and shorten Google, GitHub, AI, and blog option labels.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Sort accounts platforms alphabetically and drop connect hover plus.
Reuse connectableOptions for the accounts index and remove the unused plus badge on disconnected cards.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Centralize PostHog once-capture so disabled installs don't burn dedupe keys.
Move isEnabled + Cache::add into PostHogService::captureOnce and route onboarding viewed/step events through it.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify onboarding backfill to complete every existing open account.
Drop self-hosted and subscription filters; down clears completed_at again.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop PostHog captureOnce and use plain capture for onboarding.
Remove cache-based event dedupe; callers rely on PostHogService::capture gating.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 23:34:43 +00:00
|
|
|
use HasAccount, HasApiTokens, HasFactory, HasMedia, HasUuids, HasWorkspace, Notifiable;
|
2026-03-31 03:40:18 +00:00
|
|
|
|
2026-01-15 01:13:44 +00:00
|
|
|
/**
|
|
|
|
|
* @var list<string>
|
|
|
|
|
*/
|
|
|
|
|
protected $fillable = [
|
|
|
|
|
'name',
|
|
|
|
|
'email',
|
|
|
|
|
'password',
|
2026-05-03 20:42:44 +00:00
|
|
|
'google_id',
|
2026-05-04 21:42:25 +00:00
|
|
|
'github_id',
|
2026-04-15 01:22:04 +00:00
|
|
|
'account_id',
|
2026-01-17 02:46:30 +00:00
|
|
|
'current_workspace_id',
|
2026-01-20 19:53:54 +00:00
|
|
|
'email_verified_at',
|
2026-05-04 21:42:25 +00:00
|
|
|
'utm_source',
|
|
|
|
|
'utm_medium',
|
|
|
|
|
'utm_campaign',
|
|
|
|
|
'utm_term',
|
|
|
|
|
'utm_content',
|
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 18:07:23 +00:00
|
|
|
'gclid',
|
|
|
|
|
'fbclid',
|
|
|
|
|
'li_fat_id',
|
|
|
|
|
'ttclid',
|
|
|
|
|
'rdt_cid',
|
|
|
|
|
'epik',
|
2026-05-04 21:42:25 +00:00
|
|
|
'registration_ip',
|
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 23:40:03 +00:00
|
|
|
'persona',
|
2026-06-25 23:49:04 +00:00
|
|
|
'goals',
|
2026-07-18 19:07:11 +00:00
|
|
|
'referral_source',
|
2026-01-15 01:13:44 +00:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @var list<string>
|
|
|
|
|
*/
|
|
|
|
|
protected $hidden = [
|
|
|
|
|
'password',
|
|
|
|
|
'two_factor_secret',
|
|
|
|
|
'two_factor_recovery_codes',
|
|
|
|
|
'remember_token',
|
|
|
|
|
];
|
|
|
|
|
|
2026-03-30 02:01:08 +00:00
|
|
|
protected $appends = [
|
|
|
|
|
'has_photo',
|
|
|
|
|
'photo_url',
|
|
|
|
|
];
|
2026-01-17 17:44:37 +00:00
|
|
|
|
2026-03-30 02:01:08 +00:00
|
|
|
public function getHasPhotoAttribute(): bool
|
2026-01-17 17:44:37 +00:00
|
|
|
{
|
2026-03-30 02:01:08 +00:00
|
|
|
return $this->getFirstMedia('avatar') !== null;
|
|
|
|
|
}
|
2026-01-17 17:44:37 +00:00
|
|
|
|
2026-03-30 02:01:08 +00:00
|
|
|
public function getPhotoUrlAttribute(): ?string
|
|
|
|
|
{
|
|
|
|
|
return $this->getFirstMediaUrl('avatar');
|
2026-01-17 17:44:37 +00:00
|
|
|
}
|
|
|
|
|
|
Activation checklist + MCP OAuth authorize UX (#239) (#250)
* Wire onboarding activation into Account, observers, and shared Inertia data
Add onboarding casts/hasFinishedOnboarding, AccessToken ObservedBy,
Platform::connectableOptions, Post/SocialAccount onboarding broadcast hooks,
and lazy onboardingResidual share + SharedData types.
* Register onboarding routes and post-checkout activation redirects.
Wire billing processing and the sidebar checklist so owners land on
activation after subscribe, with locale sidebar/uk onboarding strings.
* Align MCP grant usability with onboarding activation checks
Unbound MCP tokens fall back to the user's current workspace and require
createPost so viewer/unscoped grants neither unlock the checklist nor
broadcast onboarding status.
* Require bound MCP workspace for onboarding activation.
Drop current-workspace fallback from usable MCP grants so checklist
detection and broadcasts match Passport token scoping; viewers still
cannot unlock the MCP step.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Harden onboarding review findings and tighten locale strings.
Fix Welcome/Persona/TrackPost suites broken by the activation route reuse
and PostObserver analytics side effects, restore Echo poll fallbacks,
reject unbound MCP grants in tests, and drop unused onboarding.mcp keys.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Remove unused sidebar and MCP authorization locale keys.
Drop dead sidebar menu/theme strings (including the overwritten
workspace label and api_keys nav entry) and unused MCP authorize
app_title/approving copy across all locales.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix SetLocale crashing on Passport Symfony OAuth responses.
OAuth errors return a raw Symfony Response without withCookie(); attach
the default locale cookie via headers so authorize no longer 500s.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Prompt OAuth guests to log in before rejecting unknown clients.
MCP Inspector often reuses a stale client_id; validateAuthorizationRequest
was returning invalid_client JSON before the login redirect. Guests now
hit /login first, then client validation runs after authentication.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Render Inertia OAuth authorize errors for browser logins.
After login, Inertia follows the intended authorize URL; raw invalid_client
JSON broke that visit. HTML/Inertia requests now get mcp/AuthorizeError
while API JSON clients still receive the OAuth error payload.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Detect Inertia OAuth error pages via Request::inertia().
Use the framework helper so post-login authorize failures keep returning
an Inertia page instead of raw OAuth JSON.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify OAuth authorize error page detection to expectsJson.
Drop the X-Inertia header sniff; browser and Inertia visits already do
not expectsJson, while API clients still receive the OAuth JSON payload.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Share MCP authorize layout and drop the error close button.
Keep authorize and authorize-error on the same centered card shell instead of the auth split layout.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify onboarding activation for reviewability and safety.
Use an exists-based MCP check, keep GETs read-only, move sync into
syncAndNotify, clear MCP skips on connect, restrict complete to owners,
and share Echo/poll via one composable.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Move MCP OAuth authorize UX out of the onboarding PR.
Keep the activation checklist focused; OAuth guest/error-page work now
lives on fix/mcp-oauth-authorize-ux.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix corrupted French MCP locale after OAuth key cleanup.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Restore MCP OAuth authorize UX onto the onboarding branch.
Keep authorize error page, guest login-before-client validation, and
SetLocale Symfony cookie fix in #250.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix OAuth prompt=none redirects and harden onboarding tests.
Keep login_required/consent_required as redirects instead of Inertia,
add regression coverage for owner-only activation, require invite email
confirmation, and align MCP connected apps with the sessions list UI.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify onboarding guards and dedupe viewed analytics.
Introduce isOnboardingOpen / belongsToAccount helpers, collapse
duplicated sync/dispatch paths, and capture onboarding.viewed once
per account.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify onboarding event, observers, and status helpers.
Tighten Account onboarding predicates, drop nullable broadcast/dispatch
APIs, and collapse repeated observer/controller guards.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Treat in-app users as always having an account.
Add resolveAccount(), tighten belongsToAccount to string ids, and fold
guest residual handling into ResolveOnboardingStatus.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Rename onboarding residual share test to progress.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify onboarding status and rename residual to progress.
Use accountOrFail, extract MCP onboarding scope, auto-leave the ready
screen, and send non-onboarding checkout back to accounts.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Extract HasAccount and prefer data_get in onboarding flows.
Move account helpers off User, drop nullable sidebarProgress, and
read OAuth/onboarding payloads with data_get.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify onboarding checks and extract HasOnboarding.
Use Eloquent + policies for MCP/backfill paths, and move account
onboarding helpers into a dedicated trait.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add trait tests and tidy onboarding imports.
Cover HasAccount and HasOnboarding under Models/Traits, prefer filled() for checkout session ids, and import Throwable instead of FQCN.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify checkout session_id and OAuth error props.
Read session_id via request->string(), and take OAuth error details from the League exception instead of decoding the response body.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify PostObserver onboarding notify path.
Share one otherPosts check for first-create and last-delete instead of separate callbacks.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Use post author as onboarding sync actor.
Drop Auth::user() preference in PostObserver; checklist sync attributes to $post->user.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify SocialAccountObserver and OAuth authorize flow.
Share create/delete onboarding notify, drop Auth actor fallback to owner, and inline Passport Inertia error handling.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Use lazy Inertia props for onboarding partial reloads.
Drop partial-header branching; wrap page props in closures and always redirect completed/dismissed accounts to the calendar.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Defer sidebar onboarding progress and stamp completion as owner-only.
Skip the MCP checklist work on full Inertia visits via deferred shared props,
early-exit token scans, and keep account completion stamps owner-gated.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify deferred onboarding progress share via canShowProgress.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add User firstName for shared auth and simplify onboarding page.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Move User firstName coverage into UserTest.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Use first_name directly without empty-name fallbacks.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Resolve onboarding sample prompt on the frontend via i18n.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Stamp onboarding completion via the account owner after teammate unlocks.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Count only the account owner MCP grant toward onboarding activation.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix OAuth consent auth-token mismatch for mid-activation owners.
Skip deferred onboardingProgress on Passport authorize so Inertia does not
rotate the session authToken, cover happy and stale-token paths in tests,
and polish MCP setup copy plus sidebar/onboarding layout.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Keep users on onboarding after activation completes.
Stamp completion and re-render the finished checklist instead of
redirecting to the calendar so owners can review the done state.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Clarify Passport consent-view opt-out and guard app-route deferral.
Rename the authorize-only route check and assert onboardingProgress still
defers on calendar, onboarding, and MCP settings.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Harden onboarding completion and MCP consent workspace binding.
Reject OAuth approve without a workspace, retry auto-complete until
stamped, send dismissed complete straight to calendar, and cover the
device consent defer opt-out.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Enable activation checklist for self-hosted installs.
Remove the self-hosted onboarding redirects, keep the SaaS-only dismiss backfill, and cover subscription-less owners plus skip/complete destinations.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Add GitHub, Hacker News, and directories referral sources.
Expand the welcome referral step with open-source and directory discovery channels.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Refine welcome referral sources and labels.
Split Instagram/Threads, add Founder, and shorten Google, GitHub, AI, and blog option labels.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Sort accounts platforms alphabetically and drop connect hover plus.
Reuse connectableOptions for the accounts index and remove the unused plus badge on disconnected cards.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Centralize PostHog once-capture so disabled installs don't burn dedupe keys.
Move isEnabled + Cache::add into PostHogService::captureOnce and route onboarding viewed/step events through it.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify onboarding backfill to complete every existing open account.
Drop self-hosted and subscription filters; down clears completed_at again.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Drop PostHog captureOnce and use plain capture for onboarding.
Remove cache-based event dedupe; callers rely on PostHogService::capture gating.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 23:34:43 +00:00
|
|
|
/**
|
|
|
|
|
* First whitespace-delimited token of the display name (empty when unset).
|
|
|
|
|
*/
|
|
|
|
|
public function firstName(): string
|
|
|
|
|
{
|
|
|
|
|
return (string) Str::of($this->name ?? '')->trim()->before(' ');
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 01:13:44 +00:00
|
|
|
protected function casts(): array
|
|
|
|
|
{
|
|
|
|
|
return [
|
|
|
|
|
'email_verified_at' => 'datetime',
|
|
|
|
|
'password' => 'hashed',
|
|
|
|
|
'two_factor_confirmed_at' => 'datetime',
|
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 23:40:03 +00:00
|
|
|
'persona' => Persona::class,
|
2026-06-25 23:49:04 +00:00
|
|
|
'goals' => 'array',
|
2026-07-18 19:07:11 +00:00
|
|
|
'referral_source' => ReferralSource::class,
|
2026-01-15 01:13:44 +00:00
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
feat: notification system with SendNotification job, dialog UI, tests
Backend:
- Create notifications table (user_id, workspace_id, type, channel,
title, body, data JSON, read_at, archived_at)
- Create Notification model with Type enum (post_failed,
account_disconnected, invite_received, member_joined, member_removed)
and Channel enum (email, in_app, both)
- Create SendNotification job: isolated from publish flow, handles
saving in-app notification and sending email independently
- NotificationController: index (excludes archived, scoped to workspace),
markAsRead, markAllAsRead, archiveAll
- Integrate with PublishToSocialPlatform (post failed/partial)
- Integrate with VerifyWorkspaceConnections (batch disconnection)
- Integrate with SocialAccount::markAsDisconnected (single disconnection)
- All use SendNotification::dispatch() instead of direct Mail::to()
Frontend:
- NotificationBell component in sidebar footer with unread badge
- Dialog with notification list, mark as read, mark all read, archive all
- Click navigates to relevant page (post edit, accounts)
- i18n for notifications UI (en, es, pt-BR)
Tests:
- 8 tests for NotificationController (auth, CRUD, workspace scoping)
- 4 tests for SendNotification job (channels, email, data storage)
All 745 tests passing.
2026-03-30 19:47:03 +00:00
|
|
|
public function notifications(): HasMany
|
|
|
|
|
{
|
|
|
|
|
return $this->hasMany(Notification::class);
|
|
|
|
|
}
|
|
|
|
|
|
refactor: notification preferences, header slots, calendar layout, UI polish
Notification preferences:
- Create notification_preferences table (post_published, post_failed,
account_disconnected booleans per user)
- NotificationPreferenceController with firstOrCreate on first visit
- SendNotification job respects email preferences before sending
- Settings page with toggle switches, i18n in 3 languages
- 8 new tests for preferences (controller + wantsEmailFor + job integration)
Post published notification:
- PostPublished mail + maizzle template
- Notify owner on successful publish via SendNotification job
- PostPublished type added to notification enum
Header & Layout:
- Rename AppSidebarHeader to AppHeader with left/center/right slots
- showSidebarTrigger prop to hide sidebar toggle
- Calendar: controls in header (left: nav, center: date, right: tabs + new post)
- Fixed header with scrollable content (flex h-screen pattern)
- fullWidth pages use overflow-y-auto (fixes month view scroll)
UI improvements:
- Action buttons moved to header-right: posts, hashtags, labels
- Settings breadcrumbs: "Settings > Profile" pattern
- Calendar: remove duplicate New Post button from day view
- Remove size="sm" from Schedule/Publish buttons
- Remove bg-background from header (inherits from SidebarInset)
- Add Cancel button to labels and hashtags create/edit dialogs
- Add common.cancel i18n key
- Clean up orphaned Calendar breadcrumbs
- Fix SocialAccountsGrid buttons to use shadcn Button ghost
All 753 tests passing.
2026-03-30 21:18:17 +00:00
|
|
|
public function notificationPreference(): HasOne
|
|
|
|
|
{
|
|
|
|
|
return $this->hasOne(NotificationPreference::class);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 03:40:18 +00:00
|
|
|
public function wantsEmailFor(NotificationType $type): bool
|
refactor: notification preferences, header slots, calendar layout, UI polish
Notification preferences:
- Create notification_preferences table (post_published, post_failed,
account_disconnected booleans per user)
- NotificationPreferenceController with firstOrCreate on first visit
- SendNotification job respects email preferences before sending
- Settings page with toggle switches, i18n in 3 languages
- 8 new tests for preferences (controller + wantsEmailFor + job integration)
Post published notification:
- PostPublished mail + maizzle template
- Notify owner on successful publish via SendNotification job
- PostPublished type added to notification enum
Header & Layout:
- Rename AppSidebarHeader to AppHeader with left/center/right slots
- showSidebarTrigger prop to hide sidebar toggle
- Calendar: controls in header (left: nav, center: date, right: tabs + new post)
- Fixed header with scrollable content (flex h-screen pattern)
- fullWidth pages use overflow-y-auto (fixes month view scroll)
UI improvements:
- Action buttons moved to header-right: posts, hashtags, labels
- Settings breadcrumbs: "Settings > Profile" pattern
- Calendar: remove duplicate New Post button from day view
- Remove size="sm" from Schedule/Publish buttons
- Remove bg-background from header (inherits from SidebarInset)
- Add Cancel button to labels and hashtags create/edit dialogs
- Add common.cancel i18n key
- Clean up orphaned Calendar breadcrumbs
- Fix SocialAccountsGrid buttons to use shadcn Button ghost
All 753 tests passing.
2026-03-30 21:18:17 +00:00
|
|
|
{
|
|
|
|
|
$preference = $this->notificationPreference;
|
|
|
|
|
|
|
|
|
|
if (! $preference) {
|
2026-04-15 01:22:04 +00:00
|
|
|
return true;
|
refactor: notification preferences, header slots, calendar layout, UI polish
Notification preferences:
- Create notification_preferences table (post_published, post_failed,
account_disconnected booleans per user)
- NotificationPreferenceController with firstOrCreate on first visit
- SendNotification job respects email preferences before sending
- Settings page with toggle switches, i18n in 3 languages
- 8 new tests for preferences (controller + wantsEmailFor + job integration)
Post published notification:
- PostPublished mail + maizzle template
- Notify owner on successful publish via SendNotification job
- PostPublished type added to notification enum
Header & Layout:
- Rename AppSidebarHeader to AppHeader with left/center/right slots
- showSidebarTrigger prop to hide sidebar toggle
- Calendar: controls in header (left: nav, center: date, right: tabs + new post)
- Fixed header with scrollable content (flex h-screen pattern)
- fullWidth pages use overflow-y-auto (fixes month view scroll)
UI improvements:
- Action buttons moved to header-right: posts, hashtags, labels
- Settings breadcrumbs: "Settings > Profile" pattern
- Calendar: remove duplicate New Post button from day view
- Remove size="sm" from Schedule/Publish buttons
- Remove bg-background from header (inherits from SidebarInset)
- Add Cancel button to labels and hashtags create/edit dialogs
- Add common.cancel i18n key
- Clean up orphaned Calendar breadcrumbs
- Fix SocialAccountsGrid buttons to use shadcn Button ghost
All 753 tests passing.
2026-03-30 21:18:17 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return match ($type) {
|
2026-03-31 03:40:18 +00:00
|
|
|
NotificationType::PostPublished => $preference->post_published,
|
|
|
|
|
NotificationType::PostFailed, NotificationType::PostPartiallyPublished => $preference->post_failed,
|
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 14:10:39 +00:00
|
|
|
NotificationType::AccountDisconnected, NotificationType::PostAtRisk => $preference->account_disconnected,
|
feat: @mentions in comments, AI Action layer + MCP tools, settings tabs
Mentions in post comments
- @mention autocomplete (workspace members, current user excluded) with
marker syntax @[uuid] persisted, display names rendered via CommentBody
chips; live edit replaces markers with names and converts back on save.
- NotifyMentions action with workspace-scoped membership check, dedupes
same user, only newly-added mentions on update.
- Email + in-app via SendNotification job, respecting per-user
notification_preferences.mentioned_in_comment.
- Heartbeat-based presence (Cache, 60s TTL, 30s ping) so online recipients
get only the in-app notification — no email noise.
- Real-time bell on workspace.{id}.user.{id} private channel
(NotificationCreated event), scoped channel name avoids client-side
filtering and lays out a convention for future workspace channels.
- Mailable localized via lang/{en,es,pt-BR}/mail.php; Maizzle source
template for the email is committed and built into resources/views/mail.
AI generation refactor (Action layer + MCP)
- Extracted Actions/Ai/Generate{Image,Video} with QuotaExhaustedException
so agent tools and MCP tools share a single domain entry point.
- Mcp/Tools/Ai/Generate{Image,Video}Tool registered in TryPostServer; both
return MediaResource payloads.
- Orientation::imageApiSize maps non-OpenAI ratios to 1:1/2:3/3:2.
- config/ai.php is now the single source of truth driven by env, removing
the trypost.ai shim. Default text/image providers flipped to OpenAI.
Settings/UX
- /settings/workspace split into shadcn Tabs (Workspace / Brand / Users)
with three components.
- /assets and the in-editor MediaPicker open the ImagePreviewDialog
lightbox on image click while preserving action button behaviour.
- Comments tab landed via ?tab=comments&comment=<id> from notification
click (scroll-to + temporary highlight).
- Mention autocomplete popover flips above when near the viewport bottom.
- Real social platform PNGs replace Tabler brand glyphs in schedule
pills and post list, with hover tooltip carrying display_name + handle.
Bug fixes
- AcceptInvite: controller now passes workspace + role payload that the
Vue page expects; login/register CTAs preselect the invite email.
- WorkspaceInvite mailable: stopped referencing nonexistent
$invite->workspace and $invite->role; column added to the migration,
Invite model casts role to WorkspaceRole, CreateInvite persists it.
- PostCommentCreated: added broadcastAs so .PostCommentCreated actually
matches the Echo listener; payload now includes mentioned_users so
receivers render the chip correctly without a refetch.
- Preview components for X/Pinterest/Threads/Bluesky/LinkedIn/Mastodon/
TikTok/YouTube switched from item.type === 'image' to
!isVideoMedia(item) so media without a persisted type still renders.
- UpdatePostRequest now accepts media.*.{type,mime_type,size,...} so the
posts.media JSON keeps the metadata that the previews need.
- Removed throttle:6,1 from social connect routes (was 429ing legitimate
OAuth retries).
- Used MediaType enum cases instead of literal 'image'/'video' strings
when creating media rows.
Tests
- MentionParser unit tests, NotifyMentions feature tests including
online/offline channel selection and preference gating, MCP AI tool
happy paths, MentionedInComment mailable rendering, AcceptInvite +
search-members + index mentioned_users path. 1229 passing.
2026-05-01 23:59:03 +00:00
|
|
|
NotificationType::MentionedInComment => $preference->mentioned_in_comment ?? true,
|
refactor: notification preferences, header slots, calendar layout, UI polish
Notification preferences:
- Create notification_preferences table (post_published, post_failed,
account_disconnected booleans per user)
- NotificationPreferenceController with firstOrCreate on first visit
- SendNotification job respects email preferences before sending
- Settings page with toggle switches, i18n in 3 languages
- 8 new tests for preferences (controller + wantsEmailFor + job integration)
Post published notification:
- PostPublished mail + maizzle template
- Notify owner on successful publish via SendNotification job
- PostPublished type added to notification enum
Header & Layout:
- Rename AppSidebarHeader to AppHeader with left/center/right slots
- showSidebarTrigger prop to hide sidebar toggle
- Calendar: controls in header (left: nav, center: date, right: tabs + new post)
- Fixed header with scrollable content (flex h-screen pattern)
- fullWidth pages use overflow-y-auto (fixes month view scroll)
UI improvements:
- Action buttons moved to header-right: posts, hashtags, labels
- Settings breadcrumbs: "Settings > Profile" pattern
- Calendar: remove duplicate New Post button from day view
- Remove size="sm" from Schedule/Publish buttons
- Remove bg-background from header (inherits from SidebarInset)
- Add Cancel button to labels and hashtags create/edit dialogs
- Add common.cancel i18n key
- Clean up orphaned Calendar breadcrumbs
- Fix SocialAccountsGrid buttons to use shadcn Button ghost
All 753 tests passing.
2026-03-30 21:18:17 +00:00
|
|
|
default => true,
|
|
|
|
|
};
|
|
|
|
|
}
|
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 14:47:47 +00:00
|
|
|
|
|
|
|
|
public function isConnectedTo(SocialAuthProvider $provider): bool
|
|
|
|
|
{
|
|
|
|
|
return (bool) $this->{"{$provider->value}_id"};
|
|
|
|
|
}
|
2026-01-15 01:13:44 +00:00
|
|
|
}
|