Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
7171c81
Wire onboarding activation into Account, observers, and shared Inerti…
paulocastellano Aug 7, 2026
b61ef26
Register onboarding routes and post-checkout activation redirects.
paulocastellano Aug 7, 2026
b59942a
Align MCP grant usability with onboarding activation checks
paulocastellano Aug 7, 2026
f78b6ec
Require bound MCP workspace for onboarding activation.
paulocastellano Aug 7, 2026
cf5775c
Harden onboarding review findings and tighten locale strings.
paulocastellano Aug 7, 2026
f93dbf3
Remove unused sidebar and MCP authorization locale keys.
paulocastellano Aug 7, 2026
980dce6
Fix SetLocale crashing on Passport Symfony OAuth responses.
paulocastellano Aug 7, 2026
b1013b5
Prompt OAuth guests to log in before rejecting unknown clients.
paulocastellano Aug 7, 2026
51e633e
Render Inertia OAuth authorize errors for browser logins.
paulocastellano Aug 7, 2026
ea8ec6e
Detect Inertia OAuth error pages via Request::inertia().
paulocastellano Aug 7, 2026
6074d75
Simplify OAuth authorize error page detection to expectsJson.
paulocastellano Aug 7, 2026
77031fb
Share MCP authorize layout and drop the error close button.
paulocastellano Aug 7, 2026
efe9676
Simplify onboarding activation for reviewability and safety.
paulocastellano Aug 7, 2026
a095689
Move MCP OAuth authorize UX out of the onboarding PR.
paulocastellano Aug 7, 2026
7a6e81c
Fix corrupted French MCP locale after OAuth key cleanup.
paulocastellano Aug 7, 2026
ceeb177
Restore MCP OAuth authorize UX onto the onboarding branch.
paulocastellano Aug 7, 2026
93f2a9e
Fix OAuth prompt=none redirects and harden onboarding tests.
paulocastellano Aug 7, 2026
b7d0542
Simplify onboarding guards and dedupe viewed analytics.
paulocastellano Aug 7, 2026
f753bf7
Simplify onboarding event, observers, and status helpers.
paulocastellano Aug 7, 2026
0e95e16
Treat in-app users as always having an account.
paulocastellano Aug 7, 2026
eae5d44
Rename onboarding residual share test to progress.
paulocastellano Aug 7, 2026
42b81bc
Simplify onboarding status and rename residual to progress.
paulocastellano Aug 7, 2026
003ef37
Extract HasAccount and prefer data_get in onboarding flows.
paulocastellano Aug 7, 2026
a0cbf92
Simplify onboarding checks and extract HasOnboarding.
paulocastellano Aug 7, 2026
4b1030e
Add trait tests and tidy onboarding imports.
paulocastellano Aug 7, 2026
0a4bd29
Simplify checkout session_id and OAuth error props.
paulocastellano Aug 7, 2026
9d516b4
Simplify PostObserver onboarding notify path.
paulocastellano Aug 7, 2026
0f5bd5a
Use post author as onboarding sync actor.
paulocastellano Aug 7, 2026
04ad18f
Simplify SocialAccountObserver and OAuth authorize flow.
paulocastellano Aug 7, 2026
7a60bc3
Use lazy Inertia props for onboarding partial reloads.
paulocastellano Aug 7, 2026
9bf9e6f
Defer sidebar onboarding progress and stamp completion as owner-only.
paulocastellano Aug 7, 2026
d9b5d67
Simplify deferred onboarding progress share via canShowProgress.
paulocastellano Aug 7, 2026
2bc7cdb
Add User firstName for shared auth and simplify onboarding page.
paulocastellano Aug 7, 2026
c02cffe
Move User firstName coverage into UserTest.
paulocastellano Aug 7, 2026
4d866ee
Use first_name directly without empty-name fallbacks.
paulocastellano Aug 7, 2026
912b853
Resolve onboarding sample prompt on the frontend via i18n.
paulocastellano Aug 7, 2026
f80587f
Stamp onboarding completion via the account owner after teammate unlo…
paulocastellano Aug 7, 2026
db801a4
Count only the account owner MCP grant toward onboarding activation.
paulocastellano Aug 7, 2026
0c612b4
Fix OAuth consent auth-token mismatch for mid-activation owners.
paulocastellano Aug 7, 2026
5ce1cb7
Keep users on onboarding after activation completes.
paulocastellano Aug 7, 2026
94d45de
Clarify Passport consent-view opt-out and guard app-route deferral.
paulocastellano Aug 7, 2026
d1296c1
Harden onboarding completion and MCP consent workspace binding.
paulocastellano Aug 7, 2026
9bfb20a
Enable activation checklist for self-hosted installs.
paulocastellano Aug 7, 2026
01cb895
Add GitHub, Hacker News, and directories referral sources.
paulocastellano Aug 7, 2026
9a735ea
Refine welcome referral sources and labels.
paulocastellano Aug 7, 2026
c873bf5
Sort accounts platforms alphabetically and drop connect hover plus.
paulocastellano Aug 7, 2026
9b898cc
Centralize PostHog once-capture so disabled installs don't burn dedup…
paulocastellano Aug 7, 2026
34b4a07
Simplify onboarding backfill to complete every existing open account.
paulocastellano Aug 7, 2026
54d6ef9
Drop PostHog captureOnce and use plain capture for onboarding.
paulocastellano Aug 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
462 changes: 462 additions & 0 deletions app/Actions/Onboarding/ResolveOnboardingStatus.php

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions app/Enums/PostHog/OnboardingEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace App\Enums\PostHog;

enum OnboardingEvent: string
{
case Viewed = 'onboarding.viewed';
case StepCompleted = 'onboarding.step_completed';
case StepSkipped = 'onboarding.step_skipped';
case Completed = 'onboarding.completed';
}
21 changes: 21 additions & 0 deletions app/Enums/SocialAccount/Platform.php
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,27 @@ public function isConnectable(): bool
};
}

/**
* Connectable platforms shaped for Inertia account/onboarding grids.
* Sorted alphabetically by label (ASC, case-insensitive).
*
* @return list<array{value: string, label: string, color: string, network: string}>
*/
public static function connectableOptions(): array
{
return collect(self::cases())
->filter(fn (self $platform): bool => $platform->isConnectable())
->sortBy(fn (self $platform): string => mb_strtolower($platform->label()))
->map(fn (self $platform): array => [
'value' => $platform->value,
'label' => $platform->label(),
'color' => $platform->color(),
'network' => $platform->network(),
])
->values()
->all();
}

/**
* Static, platform-specific data exposed to the frontend (e.g. TikTok privacy options,
* compliance URLs). Returns an empty array for platforms with no extra config.
Expand Down
5 changes: 5 additions & 0 deletions app/Enums/User/ReferralSource.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,15 @@ enum ReferralSource: string
case YouTube = 'youtube';
case TikTok = 'tiktok';
case Instagram = 'instagram';
case Threads = 'threads';
case Reddit = 'reddit';
case ProductHunt = 'product_hunt';
case GitHub = 'github';
case HackerNews = 'hacker_news';
case Directories = 'directories';
case AiAssistant = 'ai_assistant';
case Friend = 'friend';
case Founder = 'founder';
case Blog = 'blog';
case Other = 'other';
}
103 changes: 103 additions & 0 deletions app/Events/OnboardingStatusUpdated.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php

declare(strict_types=1);

namespace App\Events;

use App\Actions\Onboarding\ResolveOnboardingStatus;
use App\Models\Account;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;

class OnboardingStatusUpdated implements ShouldBroadcast, ShouldDispatchAfterCommit
{
use Dispatchable, InteractsWithSockets, SerializesModels;

public function __construct(public string $workspaceId) {}

/**
* Notify every workspace channel that onboarding state changed — no sync.
* Use after complete/skip so owner progress banners update immediately
* even when dispatchForAccount would early-return on stamped timestamps.
*/
public static function broadcastForAccount(Account $account): void
{
foreach ($account->workspaces()->pluck('id') as $workspaceId) {
static::dispatch((string) $workspaceId);
}
}

/**
* Sync progress then broadcast to every workspace on the account.
* Use when a step is account-scoped (e.g. MCP OAuth).
*/
public static function dispatchForAccount(Account $account, ?User $actor = null): void
{
if ($account->isOnboardingOpen()) {
static::syncAfterCommit($account->id, $actor?->id);
}
}

/**
* Sync progress for the workspace account, then broadcast.
* Actor-less flows (webhooks/jobs) fall back to the account owner.
*/
public static function dispatchForWorkspace(?string $workspaceId, ?User $actor = null): void
{
$account = filled($workspaceId)
? Workspace::query()->find($workspaceId)?->account
: null;

if ($account?->isOnboardingOpen()) {
static::syncAfterCommit($account->id, $actor?->id);
}
}

private static function syncAfterCommit(string $accountId, ?string $actorId): void
{
DB::afterCommit(function () use ($accountId, $actorId): void {
$account = Account::query()->find($accountId);

if ($account?->isOnboardingOpen()) {
app(ResolveOnboardingStatus::class)->syncAndNotify($account, $actorId);
}
});
}

public function broadcastAs(): string
{
return 'onboarding.status.updated';
}

/**
* @return array<int, PrivateChannel>
*/
public function broadcastOn(): array
{
return [
new PrivateChannel("workspace.{$this->workspaceId}"),
];
}

/**
* @return array{workspace_id: string}
*/
public function broadcastWith(): array
{
return [
'workspace_id' => $this->workspaceId,
];
}

public function broadcastQueue(): string
{
return 'broadcasts';
}
}
18 changes: 12 additions & 6 deletions app/Http/Controllers/App/BillingController.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,21 +27,27 @@ public function processing(Request $request): Response|RedirectResponse
return redirect()->route('app.calendar');
}

$account = $request->user()->account;
$sessionId = $request->query('session_id');
$user = $request->user();
$account = $user->accountOrFail();
$sessionId = $request->string('session_id')->toString();

// Consume the checkout session once: `fromCheckout` is true only the first
// time this session_id is seen, so a back-button/refresh to the success URL
// can't re-fire `checkout.completed`. `Cache::add` is atomic — it returns
// true only when the key didn't exist yet.
$fromCheckout = is_string($sessionId) && $sessionId !== ''
$fromCheckout = filled($sessionId)
&& Cache::add("checkout_tracked:{$sessionId}", true, now()->addDay());

$subscriptionActive = $account->subscribed(Account::SUBSCRIPTION_NAME);
$redirectToOnboarding = $user->isAccountOwner()
&& $account->isOnboardingOpen();

return Inertia::render('billing/Processing', [
'subscriptionActive' => $account && $account->subscribed(Account::SUBSCRIPTION_NAME),
'subscriptionActive' => $subscriptionActive,
'fromCheckout' => $fromCheckout,
'persona' => $request->user()->persona?->value,
'conversion' => $fromCheckout && $account?->stripe_id
'redirectToOnboarding' => $redirectToOnboarding,
'persona' => $user->persona?->value,
'conversion' => $fromCheckout && $account->stripe_id
? fn () => $this->buildConversionData($account, $sessionId)
: null,
]);
Expand Down
95 changes: 95 additions & 0 deletions app/Http/Controllers/App/OnboardingController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\App;

use App\Actions\Onboarding\ResolveOnboardingStatus;
use App\Enums\PostHog\OnboardingEvent;
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Http\Resources\App\SocialAccountResource;
use App\Services\PostHogService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response as InertiaResponse;
use Symfony\Component\HttpFoundation\Response;

class OnboardingController extends Controller
{
public function __construct(
private readonly ResolveOnboardingStatus $resolveOnboardingStatus,
private readonly PostHogService $postHog,
) {}

public function index(Request $request): InertiaResponse|RedirectResponse
{
$user = $request->user();
$workspace = $user->currentWorkspace;
$account = $user->accountOrFail();
// Pure read on GET — observers + POST complete/skip stamp progress.
$status = $this->resolveOnboardingStatus->handle($user);

if (data_get($status, 'dismissed_at') !== null) {
return redirect()->route('app.calendar');
}

if (
$account->isOnboardingOpen()
&& $user->isAccountOwner()
&& ! data_get($status, 'all_complete')
) {
$this->postHog->capture(
$user->id,
OnboardingEvent::Viewed->value,
account: $account,
);
}

return Inertia::render('onboarding/Index', [
'status' => fn (): array => $status,
'canSkipSteps' => fn (): bool => $user->isAccountOwner(),
'canManageAccounts' => fn (): bool => $user->can('manageAccounts', $workspace),
'canCreatePost' => fn (): bool => $user->can('createPost', $workspace),
'mcpUrl' => fn (): string => route('mcp.trypost'),
'platforms' => fn (): array => SocialPlatform::connectableOptions(),
'accounts' => fn (): array => SocialAccountResource::collection(
$workspace->socialAccounts()->orderBy('id')->get(),
)->resolve(),
]);
}

public function skipMcp(Request $request): RedirectResponse
{
abort_unless($request->user()->isAccountOwner(), Response::HTTP_FORBIDDEN);

$this->resolveOnboardingStatus->skipMcp($request->user());

return back();
}

public function complete(Request $request): RedirectResponse
{
$user = $request->user();

abort_unless($user->isAccountOwner(), Response::HTTP_FORBIDDEN);

$account = $user->accountOrFail();

if ($account->isOnboardingDismissed()) {
return redirect()->route('app.calendar');
}

if ($account->isOnboardingCompleted()) {
return redirect()->route('app.onboarding');
}

if (! data_get($this->resolveOnboardingStatus->handle($user), 'all_complete')) {
return redirect()->route('app.onboarding');
}

$this->resolveOnboardingStatus->markCompleted($user);

return redirect()->route('app.onboarding');
}
}
11 changes: 1 addition & 10 deletions app/Http/Controllers/Auth/SocialController.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,18 +38,9 @@ public function index(Request $request): Response

$this->authorize('manageAccounts', $workspace);

$platforms = collect(SocialPlatform::cases())
->filter(fn ($platform) => $platform->isConnectable())
->map(fn ($platform) => [
'value' => $platform->value,
'label' => $platform->label(),
'color' => $platform->color(),
'network' => $platform->network(),
])->values();

return Inertia::render('accounts/Index', [
'workspace' => $workspace,
'platforms' => $platforms,
'platforms' => SocialPlatform::connectableOptions(),
'connectedAccounts' => SocialAccountResource::collection(
$workspace->socialAccounts()->orderBy('id')->get(),
)->resolve(),
Expand Down
38 changes: 38 additions & 0 deletions app/Http/Middleware/App/HandleInertiaRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@

namespace App\Http\Middleware\App;

use App\Actions\Onboarding\ResolveOnboardingStatus;
use App\Enums\PostPlatform\ContentType;
use App\Http\Resources\App\HandleInertiaRequests\AuthAccountResource;
use App\Http\Resources\App\HandleInertiaRequests\AuthPlanResource;
use App\Http\Resources\App\HandleInertiaRequests\AuthUserResource;
use App\Http\Resources\App\HandleInertiaRequests\AuthWorkspaceResource;
use App\Models\User;
use Illuminate\Http\Request;
use Inertia\DeferProp;
use Inertia\Inertia;
use Inertia\Middleware;

class HandleInertiaRequests extends Middleware
Expand Down Expand Up @@ -48,6 +52,7 @@ public function share(Request $request): array
],
'usage' => $account && ! $isSelfHosted ? $account->usage() : null,
'features' => $account && ! $isSelfHosted ? $account->featureLimits() : null,
'onboardingProgress' => $this->onboardingProgress($request, $user),
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
'flash' => $request->session()->get('flash', []),
'applicationUrl' => config('app.url'),
Expand All @@ -74,4 +79,37 @@ public function shareOnce(Request $request): array
'contentTypeMediaRules' => fn (): array => ContentType::mediaRulesForFrontend(),
];
}

/**
* Defer step queries for mid-activation owners; everyone else gets false inline.
*
* Never defer on Passport consent *views*: Inertia deferred props re-request the
* same URL, Passport rotates `authToken` on every authorize hit, and approve then
* fails with InvalidAuthTokenException against the stale token still on the page.
*/
private function onboardingProgress(Request $request, ?User $user): DeferProp|false
{
if ($this->isPassportConsentViewRequest($request)) {
return false;
}

$onboarding = app(ResolveOnboardingStatus::class);

return $user && $onboarding->canShowProgress($user)
? Inertia::defer(fn (): array|false => $onboarding->sidebarProgress($user))
: false;
}

/**
* Exact GET consent-view route names only — not approve/deny, and not wildcards
* like passport.authorizations.* (those would suppress defer on POST approve too,
* which is unnecessary and easy to misread as "all OAuth").
*/
private function isPassportConsentViewRequest(Request $request): bool
{
return $request->routeIs(
'passport.authorizations.authorize',
'passport.device.authorizations.authorize',
);
}
}
4 changes: 3 additions & 1 deletion app/Http/Middleware/App/SetLocale.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,10 @@ public function handle(Request $request, Closure $next): Response

$response = $next($request);

// Passport OAuth errors return a raw Symfony Response (no withCookie()).
// Attach via headers so both Illuminate and Symfony responses work.
if (! $isValid) {
$response->withCookie(
$response->headers->setCookie(
cookie()->forever('locale', config('languages.default'), '/', config('session.domain')),
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public static function make(User $user): array
return [
'id' => $user->id,
'name' => $user->name,
'first_name' => $user->firstName(),
'email' => $user->email,
'has_photo' => $user->has_photo,
'photo_url' => $user->photo_url,
Expand Down
Loading