Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
56f1c09
fix: Facebook Page fetch missing New Pages Experience pages
StoriaJames Aug 17, 2026
1354f50
fix: Instagram-via-Facebook has the same New Pages Experience gap
StoriaJames Aug 17, 2026
77b3a73
Merge remote-tracking branch 'origin/main' into upstream-facebook-fix
paulocastellano Aug 25, 2026
e04d856
refactor: one place finds every Page a Meta login can publish to
paulocastellano Aug 25, 2026
182687d
test: pin the Page a login only reaches through a portfolio
paulocastellano Aug 25, 2026
71a423d
refactor: read the portfolio edges without asking permission first
paulocastellano Aug 25, 2026
4cf4b15
test: cover the portfolio walk's remaining shapes
paulocastellano Aug 25, 2026
a8f4161
test: stop the Meta connect tests from calling Graph for real
paulocastellano Aug 25, 2026
845f1f1
fix: tell a denied portfolio edge apart from a throttled one
paulocastellano Aug 25, 2026
7a8d680
fix: three ways the portfolio walk misread what Meta returned
paulocastellano Aug 25, 2026
d5bc967
fix: only drop a scope Meta says was refused
paulocastellano Aug 25, 2026
7bfb8df
fix: keep the portfolio walk honest and cheap
paulocastellano Aug 25, 2026
e64d5df
test: pin that pages survive past the first pooled round
paulocastellano Aug 25, 2026
229dab6
fix: keep the paging-host guard on the pooled edge walk
paulocastellano Aug 25, 2026
17a760d
fix: stop a cut-short walk from passing for a complete one
paulocastellano Aug 25, 2026
0a393f9
test: pin reconnecting a card only the portfolio still reaches
paulocastellano Aug 25, 2026
5174354
fix: an unreadable portfolio must not deny the pages that were readable
paulocastellano Aug 25, 2026
ece5f8e
fix: an incomplete walk must not answer as if it were sure
paulocastellano Aug 26, 2026
450f570
fix: stop every Inertia test from calling an SSR server
paulocastellano Aug 26, 2026
65684c8
fix: a taken slot is a fact, not a guess about the listing
paulocastellano Aug 26, 2026
caf727d
fix: bound the cursor walk by requests, and keep what it read
paulocastellano Aug 26, 2026
34278ca
refactor: one Meta connect flow instead of two kept in step by hand
paulocastellano Aug 26, 2026
128926e
fix: default Inertia SSR off, where this project already stands
paulocastellano Aug 26, 2026
856fd88
fix: one rule for a refused portfolio, and a clock on the walk
paulocastellano Aug 26, 2026
315e4bc
docs: the walk's contract changed under its own docblock
paulocastellano Aug 26, 2026
9f5f6c4
fix: put the whole callback inside the budget it advertises
paulocastellano Aug 26, 2026
117dbbb
fix: say what actually gets a Page token, per Meta's own reference
paulocastellano Aug 26, 2026
bf4cadd
fix: one budget for the callback, not one per phase of it
paulocastellano Aug 26, 2026
9658a0c
fix: a Pages throttle on a user token was reading as a refusal
paulocastellano Aug 26, 2026
c10d5ab
fix: a reconnect no longer loses its handle to a slow Graph
paulocastellano Aug 26, 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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ SELF_HOSTED=true
# Allow more than one connected account per social network in a workspace.
# Independent of SELF_HOSTED (Cloud default is false). Self-hosted typically wants true.
ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=true
META_PAGE_WALK_SECONDS=20

# Passport OAuth keys (API tokens / MCP). Prefer env vars over key files so
# every node behind a load balancer shares the same key pair. Use literal \n
Expand Down
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,12 @@ Vue components must have a single root element.

- Always use arrow functions in Vue components and TypeScript files. Never use `function` declarations.

## Inertia SSR

- This project does **not** run Inertia SSR. `config/inertia.php` defaults `ssr.enabled` to `false` and nothing in the repo sets `INERTIA_SSR_ENABLED`.
- Keep it off. With it on, every test rendering an Inertia page issues a real HTTP request to the SSR endpoint, which fails silently and falls back to client rendering — slow, and it hides missing `Http::fake()` stubs.
- The build wiring is still shipped (`resources/js/ssr.ts`, `vite.config.ts`, `npm run build:ssr` in `docker/Dockerfile`). Turning SSR on means building that bundle and running `inertia:start-ssr` alongside the app, not just flipping the env.

## Dialogs

- In `<DialogFooter>`, put the **primary action button first** in the markup, then secondary/cancel (e.g. Save → Cancel). `DialogFooter` uses `flex-col` on mobile (primary on top, cancel at the bottom) and `sm:flex-row sm:justify-start` on desktop, so the first child is the leftmost action on larger screens.
Expand Down
11 changes: 6 additions & 5 deletions app/Exceptions/Social/IncompleteMetaGraphPaginationException.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@
use Throwable;

/**
* Thrown when a Meta Graph edge could not be fully fetched — the first page
* failed, a later page failed, or pagination stopped pathologically. Callers
* must not treat this as an empty or complete list (e.g. "no pages" or
* auto-connect when count === 1).
* A Meta Graph edge could not be fully fetched. Callers must not read this as an
* empty or complete list.
*
* `$transient` separates a throttle or an upstream hiccup, where the real list is
* unknown, from a confirmed rejection, where Meta has answered. Unknown by default.
*/
class IncompleteMetaGraphPaginationException extends RuntimeException
{
public function __construct(?Throwable $previous = null)
public function __construct(?Throwable $previous = null, public readonly bool $transient = true)
{
parent::__construct('Meta Graph pagination did not complete.', previous: $previous);
}
Expand Down
56 changes: 28 additions & 28 deletions app/Http/Controllers/Auth/FacebookController.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,22 @@
use App\Exceptions\SocialAccount\ConnectPopupException;
use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException;
use App\Models\SocialAccount;
use App\Services\Social\Meta\GraphPaginator;
use App\Services\Social\Meta\ManagedPages;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Uri;
use Inertia\Inertia;
use Inertia\Response as InertiaResponse;
use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpFoundation\Response;

class FacebookController extends SocialController
class FacebookController extends MetaController
{
protected string $driver = 'facebook';
protected string $pageFields = 'id,name,username,picture{url},access_token';

protected string $noPagesKey = 'accounts.popup_callback.no_facebook_pages';

protected SocialPlatform $platform = SocialPlatform::Facebook;

Expand All @@ -33,6 +34,7 @@ class FacebookController extends SocialController
'pages_read_engagement',
'pages_manage_posts',
'read_insights',
'business_management',
];

public function connect(Request $request): Response
Expand Down Expand Up @@ -63,27 +65,29 @@ public function callback(Request $request): InertiaResponse|RedirectResponse
try {
$socialUser = Socialite::driver($this->driver)->usingGraphVersion($this->graphVersion())->user();

// Trigger public_profile and pages_show_list API calls
// These calls are needed for Meta app review permission verification
Http::get(config('trypost.platforms.facebook.graph_api').'/me', [
'fields' => 'id,name',
'access_token' => $socialUser->token,
]);
$this->touchProfile($socialUser->token);

$pages = $this->fetchPages($socialUser->token);
$granted = $this->grantedScopes($socialUser->token);

if ($granted instanceof InertiaResponse) {
return $granted;
}

$walk = ManagedPages::forUser($this->graphApi(), $socialUser->token, $this->pageFields, $granted, $this->deadline());
$listed = $this->toPageCards($walk->pages);
$pages = ManagedPages::publishable($listed);

if (empty($pages)) {
return $this->popupCallback(false, __('accounts.popup_callback.no_facebook_pages'), $this->platform->value);
return $this->noPagesOnOffer($walk, $listed);
}

$pages = $this->filterConnectableIdentities($workspace, $pages, 'id', $reconnect);

if (empty($pages)) {
return $this->noConnectableIdentities($reconnect, 'page_not_found');
return $this->noConnectableIdentities($reconnect, 'page_not_found', $walk->complete);
}

// If only one page, connect directly
if (count($pages) === 1) {
if (count($pages) === 1 && ($walk->complete || $reconnect !== null)) {
$page = $pages[0];
$avatarPath = uploadFromUrl(data_get($page, 'picture'));

Expand All @@ -98,7 +102,7 @@ public function callback(Request $request): InertiaResponse|RedirectResponse
'access_token' => data_get($page, 'access_token'),
'refresh_token' => null,
'token_expires_at' => null,
'scopes' => $this->scopes,
'scopes' => $granted,
'status' => Status::Connected,
'error_message' => null,
'disconnected_at' => null,
Expand All @@ -119,6 +123,7 @@ public function callback(Request $request): InertiaResponse|RedirectResponse
'facebook_oauth' => [
'user_token' => $socialUser->token,
'user_id' => $socialUser->getId(),
'scopes' => $granted,
'pages' => $pages,
'reconnect_id' => $reconnect?->id,
],
Expand Down Expand Up @@ -192,7 +197,7 @@ public function select(Request $request): InertiaResponse
'access_token' => data_get($selectedPage, 'access_token'),
'refresh_token' => null,
'token_expires_at' => null,
'scopes' => $this->scopes,
'scopes' => data_get($oauthData, 'scopes', $this->scopes),
'status' => Status::Connected,
'error_message' => null,
'disconnected_at' => null,
Expand All @@ -219,17 +224,12 @@ public function select(Request $request): InertiaResponse
}
}

private function fetchPages(string $userToken): array
/**
* @param array<int, array<string, mixed>> $pages
* @return list<array<string, mixed>>
*/
private function toPageCards(array $pages): array
{
$pages = GraphPaginator::all(
config('trypost.platforms.facebook.graph_api').'/me/accounts',
[
'access_token' => $userToken,
'fields' => 'id,name,username,picture{url},access_token',
'limit' => 100,
],
);

return collect($pages)->map(fn (array $page) => [
'id' => data_get($page, 'id'),
'name' => data_get($page, 'name'),
Expand All @@ -241,6 +241,6 @@ private function fetchPages(string $userToken): array

private function graphVersion(): string
{
return Uri::of(config('trypost.platforms.facebook.graph_api'))->path();
return Uri::of($this->graphApi())->path();
}
}
Loading