# Facebook Connection (Manage · `Src\Facebook`)

**Context:** Manage portal · **Routes:** `manage.facebook.*` (`/manage/facebook`) · **Permissions:** `view-meta-all|view-meta-group|view-meta-team|view-meta-own` (read, via `Permission::viewMetaAny()`) + `manage-meta` (connect / disconnect / add system user — the **writes** only; `token-debug` is read-only, see the permissions table) · **Nav:** "Meta Account" in the Project suite; in the Operations suite the page is reached through **Funnels → Meta Ads → Setting → Account** (`Components/FunnelMarketingTabs.vue`)

The **single source of Meta identity** for the whole app: an admin connects a Facebook/Meta account here, and this module stores the account, its **access token**, its **Pages**, and its **Ad Accounts**. Every other Meta feature — ad-spend insights, lead-gen campaign sync, the Messenger inbox — **borrows the token stored here**. Two ways in: the classic **OAuth browser redirect** and a **paste-a-token** shortcut (a Business "System User" token that never expires).

## Where this sits in the Meta / Facebook surface

Four modules share the same Meta plumbing but own different concerns:

| Module | Owns | Handbook |
|---|---|---|
| **`Src\Facebook`** (this doc) | OAuth tokens, Pages, Ad Accounts | here |
| **`Src\Marketing`** | Ad-spend insights + campaign→project routing | [`../marketing/readMe.md`](/docs/modules_handbook/manage/meta-ads/marketing/readMe.md) |
| **`Src\FacebookLeadGenerator` (FLG)** | Instant-Form lead intake, campaign launching, the shared `/webhooks/flg-meta` ingress | `src/FacebookLeadGenerator/*` |
| **`Src\Messenger`** | Two-way Page inbox (reuses the Pages connected here) | [`messages/messenger/readMe.md`](/docs/modules_handbook/manage/messages/messenger/readMe.md) |

**Boundary in one line:** Facebook = auth · FLG = forms + webhook · Marketing = spend insight + campaign-to-project routing · Messenger = Page chat.

## What it does
- **Connect an account (OAuth).** `connect` builds a Facebook consent URL (with a CSRF `state` in the session) and redirects the admin to Facebook. On return, `callback` exchanges the code for a **long-lived (~60-day) token**, reads the account's identity + **all Business portfolios**, collects every **Page** (personal `/me/accounts` + each portfolio's owned/client pages) and every **Ad Account**, then persists all of it.
- **Connect an account (System User token).** `connectSystemUser` skips the browser: an admin pastes a long-lived **System User** token (created in Facebook Business settings) + an optional label. The token is verified against `/me`, then the same Pages/Ad-Accounts sync runs. System-user tokens **do not expire** — ideal for a stable automation account.
- **Auto-subscribe leadgen.** After a connect, each Page is subscribed to the **`leadgen`** webhook field (so Lead-Ad submissions flow to `/webhooks/flg-meta`). This step is **fail-soft**: a failure only logs a warning, the connection still succeeds.
- **List / disconnect.** `index` shows one card per connected account (type, status pill, Pages, Ad Accounts), with a per-Page **Enable/Disable Messenger** toggle. `destroy` revokes an account.
- **Token debug.** `token-debug` returns diagnostic JSON (granted scopes + live-visible ad accounts + a 6-char token tail) for each visible integration, and also tests the standalone `META_ACCESS_TOKEN` env token. ⚠️ It is a **read** endpoint gated by `Permission::viewMetaAny()` alone — **not** by `manage-meta` (see the permissions table).

## How it works
- **Two entry paths, one store sequence.** Both `callback` (OAuth) and `connectSystemUser` end at the same three steps: `FacebookIntegrationRepository::upsert()` → `FacebookPageRepository::syncForIntegration()` → `FacebookAdAccountRepository::syncForIntegration()`, then the fail-soft leadgen subscribe.
- **Reconnect = replace, not duplicate.** `upsert()` keys on `(user_id, fb_user_id)` with `withTrashed()` — reconnecting the same FB account **restores/updates the same row** (and keeps its `group_id` once set) instead of making a second one.
- **Tokenless-page filter.** `business_management` enumerates *all* portfolio Pages, but Pages the user didn't actually grant come back **without an `access_token`**; those are dropped so they never show as "connected" or fail later at campaign launch.
- **Graph client — `app/Helpers/FacebookClient.php`.** Thin wrapper over `config('services.facebook.*')`; base URL `https://graph.facebook.com/{graph_version}` (default `v21.0`). Key methods: `buildAuthUrl`, `exchangeCodeForToken`, `exchangeForLongLivedToken`, `getUser`, `getPages`, `getBusinesses`, `getBusinessPages`, `getPermissions`, `getAdAccounts`, `subscribePageToLeadgen` (⚠️ `subscribePage` **replaces the whole webhook field set** each call — a caller wanting Messenger too must pass the union).
- **Group scoping (`group_id`).** `AccountVisibility::applyMetaIntegrations()` filters every read by the admin's Meta view level: `*-all` → everything; `*-group` → the agency group's rows; `*-team` → the team's; `*-own` → only what you connected. `upsert()` stamps `group_id = user->groupId()` on create (back-filled on update only if still null). `destroy` re-scopes the lookup through the same filter, so an admin can only delete an integration they may see.
- **⚠️ Tokens are stored in plaintext.** `facebook_integrations.access_token` and `facebook_pages.page_access_token` are plain `TEXT` with **no `encrypted` cast** — unlike the AI module (which encrypts provider keys) and Messenger (which stores `page_access_token` encrypted). Only the *display* layer redacts (token-debug shows a 6-char tail). **Follow-up:** add `'access_token' => 'encrypted'` / `'page_access_token' => 'encrypted'` casts + a one-time backfill for parity.

## Token lifecycle & expiry resilience

An OAuth connection yields a **long-lived (~60-day) user token** that **cannot be silently auto-refreshed** — Meta requires the user to re-authenticate to renew it. Only a **System User token** (`type=3`, `token_expires_at=null`) never expires. So expiry is a real operational risk, handled in three layers instead of a (impossible) refresh job:

- **Warn before it bites.** Pages that depend on a live token — Facebook Connections, Campaign Mapping, FLG Leads — render a **`MetaTokenBanner`** (amber "expires in N days" / rose "expired") fed by the **`ProvidesMetaTokenAlert`** controller trait (`metaTokenAlert(User)`), which reads the most-urgent visible integration (7-day window). Proactively, **`flg:check-token-expiry`** (daily 08:00) fires the **`facebook.token_expiring`** Notify event to subscribed admins' phones. System-user tokens (null expiry) never trigger either.
- **Never lose a lead while expired.** Lead-gen webhook delivery is authenticated by the **app secret**, not the token, so events still arrive when the token is dead — but the Graph fetch that hydrates the lead's fields needs a live token. The webhook therefore **captures first**: every `leadgen` event is persisted to **`flg_webhook_events`** (PENDING) *before* any fetch, then a queued **`ProcessFlgLeadWebhook`** job hydrates it via the shared **`FlgLeadIngestor`**. A `190` (expired) fetch flips the integration to `STATUS_EXPIRED` and leaves the event PENDING — the lead is held, not dropped.
- **Recover automatically, then reconnect.** **`flg:retry-webhook-events`** (hourly) re-dispatches PENDING events (capped at `MAX_SWEEP_ATTEMPTS`); a **reconnect** replays *all* PENDING events unconditionally via **`ReplayPendingFlgWebhooksAction`** (a fresh token unblocks them) and the connect flash reports how many are re-syncing. As a belt-and-braces safety net for events that never arrived at all (a lapsed Page subscription, downtime), **`flg:backfill-leads`** / the **"Recover leads"** button on Campaign Mapping pull straight from `/{form_id}/leads` (via `FacebookAdsService::formLeadsWithToken`) and ingest anything missing. Every path dedups on the leadgen id (`flg_leads.meta_leadgen_id` unique + the ledger), so re-running never double-counts, and a backfilled lead closes any matching PENDING ledger row.

> All of the above is bounded by **Meta's ~90-day lead retention** — recovery works only while Meta still holds the lead, which is why the expiry warning (reconnect *before* the token lapses for long) still matters.

**`flg_webhook_events`** (migration `2026_07_28_100000`) — the durable ledger: `meta_leadgen_id` (unique dedup key), `form_id`, `page_id`, `payload` (raw `changes[].value`), `status` (1 PENDING / 2 PROCESSED / 3 SKIPPED), `attempts`, `last_error`, `flg_lead_id`, `processed_at`. Model `Src\FacebookLeadGenerator\WebhookEvent`, writes via `WebhookEventRepository` (`capture`, `markProcessed`, `markSkipped`, `markAttemptFailed`).

## Tables & columns

No schema-level foreign keys (relationships are managed in Eloquent per GUIDELINES §7). `created_by` / `updated_by` / `deleted_by` are auto-filled **blame** columns (`RecordsBlame` trait).

### `facebook_integrations` — one row per connected Meta account (18 columns)
Key model: `uuid` (HasUuid) + soft delete + blame. Migration `2026_06_10_100001` (+ `group_id` from `2026_07_06_100006`).

| Column | Type | Null | Meaning |
|---|---|---|---|
| `id` | bigIncrements | no | Primary key; target of every child `integration_id`. |
| `uuid` | uuid (unique) | no | Public id used in URLs / route-model binding. |
| `user_id` | unsignedBigInteger (idx) | no | The app user who connected this account. |
| `group_id` | unsignedBigInteger (idx) | yes | Agency-group ownership (added later; drives group-scoped visibility). |
| `fb_user_id` | string(60) (idx) | no | Facebook's own user id from OAuth. |
| `type` | unsignedTinyInteger | no | `1`=Personal `2`=Business `3`=System User. Default `TYPE_USER`. |
| `fb_business_id` | string(60) | yes | Facebook Business Manager id (business connections). |
| `business_name` | string(120) | yes | Business Manager display name. |
| `access_token` | **text** | no | The account's OAuth / system-user token. **Plaintext** — see the warning above. |
| `token_expires_at` | timestamp | yes | Token expiry; `null` = never expires (system-user). |
| `status` | unsignedTinyInteger | no | `1`=Active `2`=Expired `3`=Revoked. Default `STATUS_ACTIVE`. |
| `metadata` | json | yes | Free-form bag (e.g. `connected_at`). |
| `created_by` | unsignedInteger | yes | Blame. |
| `updated_by` | unsignedInteger | yes | Blame. |
| `deleted_by` | unsignedInteger | yes | Blame (on soft delete). |
| `created_at` | timestamp | yes | — |
| `updated_at` | timestamp | yes | — |
| `deleted_at` | timestamp | yes | Soft-delete marker; `null` = active. |

### `facebook_pages` — a Page under an integration (10 columns)
Child table: **no** uuid, **no** soft delete. Migration `2026_06_10_100002`. Unique `(integration_id, page_id)`.

| Column | Type | Null | Meaning |
|---|---|---|---|
| `id` | bigIncrements | no | Primary key. |
| `integration_id` | unsignedBigInteger (idx) | no | → `facebook_integrations.id`. |
| `page_id` | string(60) (idx) | no | Facebook Page id. |
| `page_name` | string(160) | no | Page display name. |
| `page_access_token` | **text** | no | Page-scoped token for Page API calls. **Plaintext** — see the warning above. |
| `page_picture` | string(512) | yes | Page avatar URL (public `graph…/{id}/picture` endpoint). |
| `created_by` | unsignedInteger | yes | Blame. |
| `updated_by` | unsignedInteger | yes | Blame. |
| `created_at` | timestamp | yes | — |
| `updated_at` | timestamp | yes | — |

### `facebook_ad_accounts` — an ad account under an integration (9 columns)
Child table: **no** uuid, **no** soft delete. Migration `2026_06_10_100003`. Unique `(integration_id, account_id)`.

| Column | Type | Null | Meaning |
|---|---|---|---|
| `id` | bigIncrements | no | Primary key. |
| `integration_id` | unsignedBigInteger (idx) | no | → `facebook_integrations.id`. |
| `account_id` | string(60) (idx) | no | Meta ad-account id, e.g. `act_123456789`. |
| `account_name` | string(160) | no | Ad-account display name. |
| `account_status` | unsignedTinyInteger | yes | Meta status code (`1`=active `2`=disabled). |
| `created_by` | unsignedInteger | yes | Blame. |
| `updated_by` | unsignedInteger | yes | Blame. |
| `created_at` | timestamp | yes | — |
| `updated_at` | timestamp | yes | — |

## Model constants (`src/Facebook/FacebookIntegration.php`)
- **Types:** `TYPE_USER=1`, `TYPE_BUSINESS=2`, `TYPE_SYSTEM_USER=3` → `TYPES` (`Personal Account` / `Business Account` / `System User`).
- **Statuses:** `STATUS_ACTIVE=1`, `STATUS_EXPIRED=2`, `STATUS_REVOKED=3` → `STATUSES` (with UI colours emerald / amber / rose).
- Relationships: `pages()` HasMany, `adAccounts()` HasMany, `user()` BelongsTo. Helpers: `isActive()`, `isExpired()`, `typeName()`.

## Config / env (`config/services.php`)
- **`services.facebook`** (used by `FacebookClient`, the OAuth flow): `META_APP_ID`, `META_APP_SECRET`, `FACEBOOK_GRAPH_VERSION` (default `v21.0`), `FACEBOOK_CONFIGURATION_ID` (optional Login config), and a **hardcoded `scopes` array**: `ads_management, ads_read, business_management, leads_retrieval, pages_manage_ads, pages_manage_metadata, pages_messaging, pages_read_engagement, pages_show_list`.
- **`services.meta`** (WhatsApp/Messenger stack; only `token-debug` reads it here): `META_ACCESS_TOKEN`, `META_WEBHOOK_VERIFY_TOKEN`, `META_GRAPH_VERSION` (default `v19.0`), etc. `META_APP_ID`/`META_APP_SECRET` are shared with `services.facebook`.

## Permissions (`src/Auth/Permission.php`)
Meta accounts are a **scoped resource** (mutually-exclusive view levels + one manage flag), resolved by `AccountVisibility`:

| Constant | String | Grants |
|---|---|---|
| `VIEW_META_ALL` | `view-meta-all` | See all connected Meta accounts (org-wide). |
| `VIEW_META_GROUP` | `view-meta-group` | See accounts within your agency group. |
| `VIEW_META_TEAM` | `view-meta-team` | See accounts within your team. |
| `VIEW_META_OWN` | `view-meta-own` | See only accounts you connected. |
| `MANAGE_META` | `manage-meta` | Connect / disconnect / add system user — the four **write** routes only. |

`Permission::viewMetaAny()` OR-joins the four view levels for the route middleware. Grouped in the Roles UI under "Meta Accounts" (a scoped group → radio levels).

⚠️ **`token-debug` is NOT behind `manage-meta`.** The group opens with `permission:Permission::viewMetaAny()`, and only `connect` / `callback` / `system-user.store` / `destroy` add `permission:manage-meta` on top — `token-debug` does not ([`routes/web.php`](/routes/web.php) ~1013-1020). So **any** admin holding a single `view-meta-*` level can read every visible integration's granted scopes, live-visible ad accounts and 6-char token tail. **Follow-up:** if that disclosure is not intended, the fix belongs on the route (add `permission:manage-meta`), not here.

**Note:** `manage-meta` gates this module only, but the four **view** levels do not: `AccountVisibility::applyMessengerChannels()` resolves from the same `VIEW_META_*` constants to scope the Messenger Page list in the shared inbox — a Messenger channel *is* a Page under a connected Meta account, so it inherits the Meta view set (enabling/disabling a Page is separate, `manage-messenger`). The Marketing pages use their own `*-marketing` pair (see the marketing doc).

## Related files
**Controller** — [`app/Http/Controllers/Manage/Facebook/FacebookAuthController.php`](/app/Http/Controllers/Manage/Facebook/FacebookAuthController.php) (`index`, `connect`, `callback`, `tokenDebug`, `connectSystemUser`, `destroy`)
**Form Request** — [`app/Http/Requests/Manage/Facebook/SystemUserTokenRequest.php`](/app/Http/Requests/Manage/Facebook/SystemUserTokenRequest.php) (`token` required min:20, `label` nullable)
**Models** — [`src/Facebook/FacebookIntegration.php`](/src/Facebook/FacebookIntegration.php) · [`src/Facebook/FacebookPage.php`](/src/Facebook/FacebookPage.php) · [`src/Facebook/FacebookAdAccount.php`](/src/Facebook/FacebookAdAccount.php)
**Repositories** — [`src/Facebook/Repositories/FacebookIntegrationRepository.php`](/src/Facebook/Repositories/FacebookIntegrationRepository.php) (`upsert`, `markExpired`, `revoke`) · `FacebookPageRepository.php` (`syncForIntegration`, `remove`) · `FacebookAdAccountRepository.php` (`syncForIntegration`)
**Facades** — `src/Facebook/Facades/{FacebookIntegrationRepository,FacebookPageRepository,FacebookAdAccountRepository}.php`
**Graph client** — [`app/Helpers/FacebookClient.php`](/app/Helpers/FacebookClient.php)
**Visibility** — [`src/Auth/Support/AccountVisibility.php`](/src/Auth/Support/AccountVisibility.php) (`applyMetaIntegrations`, `applyOwned`, `level`)
**Frontend** — [`resources/js/Pages/Manage/Facebook/Index.vue`](/resources/js/Pages/Manage/Facebook/Index.vue) · nav in `resources/js/Layouts/ManageLayout.vue` ("Meta Account")
**Config** — [`config/services.php`](/config/services.php) (`facebook`, `meta` blocks)
**Migrations** — `database/migrations/2026_06_10_100001_create_facebook_integrations_table.php` · `…100002_create_facebook_pages_table.php` · `…100003_create_facebook_ad_accounts_table.php` · `2026_07_06_100006_add_group_id_to_facebook_integrations_table.php`
**Routes** — [`routes/web.php`](/routes/web.php) (`manage.facebook.*`, ~lines 1012-1020): `index` · `connect` · `callback` · `token-debug` · `system-user.store` · `destroy` — the group carries `['auth','admin','permission:viewMetaAny()']`, and every route **except `token-debug`** adds `permission:manage-meta`

**See also:** [Marketing / Ad Insights](/docs/modules_handbook/manage/meta-ads/marketing/readMe.md) · [Messenger](/docs/modules_handbook/manage/messages/messenger/readMe.md) · [People / Roles](/docs/modules_handbook/manage/people/roles/readMe.md) (scoped permissions)
