# ARB — AI-Driven Marketing Automation

Describe a campaign in chat and let AI generate the ad creative, write the copy, and launch it across Meta, Google, TikTok, Taboola, and LinkedIn. See `docs/TICKETS.md` for the full product spec and `docs/SERP-1827-PLAN.md` for the implementation plan and phase status.

## Tech Stack

| Layer | Choice | Notes |
|---|---|---|
| Language / Framework | PHP 8.5, Laravel 13 | See [Prerequisites](#project-setup-dev) below for the `php8.5`/Composer caveat on this box |
| Frontend interactivity | [Livewire 3](https://livewire.laravel.com/) + [Volt](https://livewire.laravel.com/docs/volt) | Auth pages (`resources/views/livewire/pages/auth/*`) are Volt single-file components; everything else is classic Livewire class + Blade view pairs under `app/Livewire` / `resources/views/livewire` |
| Templating | Blade | `.blade.php` views in `resources/views`, no JSX/Vue/React anywhere |
| CSS/JS | **Bootstrap 5.3.3 via CDN only** | Loaded as plain `<link>`/`<script>` tags in `resources/views/layouts/app.blade.php` and `welcome.blade.php`. **There is no Vite/webpack/npm build step and no `node_modules`.** `package.json` intentionally has no dependencies — don't add a JS bundler or introduce `resources/js`/`resources/css` build entries without discussing it first. Style pages with Bootstrap utility/component classes directly in Blade; use plain `<script>` blocks for any bespoke JS |
| Database | MySQL (`arb` database) | `DB_CONNECTION=mysql`; sessions/cache/queue also use it (`SESSION_DRIVER=database`, `CACHE_STORE=database`, `QUEUE_CONNECTION=database`) |
| AI provider | [`laravel/ai`](https://laravel.com/docs/ai) (OpenAI by default, `config/ai.php`) | Text agents (structured output), `Image::of()->generate()`, `Audio::of()->generate()` (TTS). It does **not** do video generation |
| Auth scaffolding | [Laravel Breeze](https://laravel.com/docs/starter-kits) (Livewire + Volt variant) | Login/register/password-reset/email-verification |
| Queue | Database queue driver | Jobs in `app/Jobs` (e.g. image/video generation) are processed by a short-lived `queue:work --stop-when-empty` invocation kicked off every minute by the scheduler (`routes/console.php`) — there's no Redis/Supervisor/long-running worker daemon in this environment, so `php artisan schedule:run` must be wired to a cron entry (or `schedule:work` in local dev) for jobs to actually process |
| Testing | PHPUnit (`tests/Feature`, `tests/Unit`) | `laravel/ai` ships fakes (`Image::fake()`, `StructuredAnonymousAgent::fake()`) used in `tests/Feature/GenerateImageCreativeJobTest.php` |

## Architecture: contracts, services, and providers

The core design principle is that **every external capability (AI generation, ad-platform API) sits behind a PHP interface (`app/Contracts`)**, with the concrete implementation swappable via config or a service provider binding — never referenced directly by controllers/jobs/Livewire components. This is what lets stub/unimplemented platforms and not-yet-chosen vendors coexist with real ones without special-casing call sites.

```
app/
├── Contracts/          Interfaces only — the extensibility seam (PlatformAdapter, ImageCreativeGenerator, ...)
├── Services/
│   ├── AI/              laravel/ai-backed implementations of the AI contracts (Laravel*Generator/*Resolver/*Synthesizer)
│   └── Platforms/       One adapter per ad platform (MetaAdapter, GoogleAdapter, ...) + PlatformAdapterFactory
├── DTOs/                Plain data objects passed between layers (CreativeBrief, CompetitorAd, VideoRenderResult)
├── Models/              Eloquent models (Creative, Campaign, CampaignCreative, AdCopy, Avatar, Voice, CompetitorAdReference, User)
├── Jobs/                Queued work (GenerateImageCreativeJob, GenerateAdCopyJob) — catch failures, set status=failed + failure_reason, safe to retry
├── Livewire/            Livewire component classes (Forms/, Actions/); Volt pages live only under resources/views/livewire/pages
├── Http/Controllers/    Thin controllers (CreativeController) — validate, delegate to models/jobs, return a view
├── Providers/
│   ├── AppServiceProvider.php      Framework-wide bootstrapping (e.g. Paginator::useBootstrapFive())
│   └── CreativeServiceProvider.php Binds every Contract to its implementation (see below)
└── Exceptions/          PlatformNotImplementedException, ProviderNotConfiguredException, etc.
```

`app/Providers/CreativeServiceProvider.php` binds contracts to implementations, some hard-coded and some config-driven:

```php
$this->app->bind(ImageCreativeGenerator::class, LaravelAiImageGenerator::class);
$this->app->bind(VideoAvatarProvider::class, fn ($app) => $app->make(config('creative.video_provider')));
```

`config/creative.php` is the single place that maps a platform name (`meta`, `google`, ...) to its adapter class, and which vendor backs `video_provider`/`competitor_ad_provider` when one hasn't been chosen yet (currently `Null*Provider` stubs that throw `ProviderNotConfiguredException`). `App\Services\Platforms\PlatformAdapterFactory` resolves `Campaign::platform` to a bound `PlatformAdapter` at runtime via the container.

Video rendering is routed by requested duration via `App\Services\AI\VideoAvatarProviderFactory::forDuration()`: durations at/under `config('creative.veo_max_duration')` (8s) use Google Veo (`video_provider`), longer requests use OpenAI Sora (`video_provider_long`), since Veo hard-caps a single clip at 8s while Sora can reach further with a base clip + one extension call.

## Adding a new module

**New ad platform (e.g. "Snapchat"):**
1. Create `app/Services/Platforms/SnapchatAdapter.php` implementing `App\Contracts\PlatformAdapter` (`createCampaign`, `attachCreative`, `publish`). Until the real API integration is ready, extend `UnimplementedPlatformAdapter` so it throws `PlatformNotImplementedException` cleanly.
2. Register it in `config/creative.php` under `platform_adapters`.
3. No controller/job/Livewire changes needed — `PlatformAdapterFactory::forPlatform('snapchat')` picks it up automatically.
4. Add feature tests mocking the HTTP client (see `MetaAdapter` + its tests for the pattern) — never hit a real ad platform API in tests.

**New AI capability or provider swap:**
1. Define (or reuse) an interface in `app/Contracts/`.
2. Implement it in `app/Services/AI/` (e.g. `LaravelAi*` for `laravel/ai`-backed, or a vendor-named class for a direct HTTP integration).
3. Bind it in `CreativeServiceProvider` (hard binding) or via a new `config/creative.php` key (if the vendor is expected to change).

**New Eloquent model / data:** add a migration under `database/migrations`, the model in `app/Models`, and wire relationships on `User`/`Campaign`/`Creative` as needed — see `docs/SERP-1827-PLAN.md` for the current schema.

**New page/feature (UI):**
- Prefer a Livewire component (`php artisan make:livewire Feature/Name`) for anything with state/interactivity; class goes in `app/Livewire`, view in `resources/views/livewire/...`.
- Use a Volt single-file component only for simple, self-contained pages (follow the existing `resources/views/livewire/pages/auth/*.blade.php` pattern) — otherwise prefer the class-based Livewire style already used elsewhere.
- Style with Bootstrap classes only; extend `layouts/app.blade.php` (authenticated) or `layouts/guest.blade.php` for the shell. Do not add a JS build step.
- Add the route in `routes/web.php` (or a new `routes/*.php` file required from it) behind `['auth', 'verified']` middleware unless it's public.

**New background job:** `php artisan make:job YourJob`; mirror `GenerateImageCreativeJob`'s pattern — wrap provider calls in try/catch, persist a `failed` status + `failure_reason` on the owning model instead of letting the job die silently, and keep it idempotent (safe to re-run on retry).

## Project Setup (Dev)

Steps to get the app running locally after pulling latest changes. This project runs on **PHP 8.5**, and the system's default `composer` binary resolves to an incompatible PHP version — always invoke Composer as `php8.5 /usr/bin/composer` (not plain `composer`).

Styling is [Bootstrap](https://getbootstrap.com/) loaded via CDN. There is no frontend build, but browser automation features require Node, npm, and a Puppeteer browser. For production deployments, see `docs/PRODUCTION-DEPLOYMENT.md`.

### Local Setup


1. **Install PHP dependencies**
   ```bash
   php8.5 /usr/bin/composer install
   ```

2. **Environment file**
   ```bash
   cp .env.example .env
   php8.5 artisan key:generate
   ```
   Update `APP_URL` in `.env` to match your local dev URL.

3. **Database (MySQL)**
   This project uses MySQL (`DB_CONNECTION=mysql`). Set `DB_HOST`/`DB_PORT`/`DB_DATABASE`/`DB_USERNAME`/`DB_PASSWORD` in `.env` to point at an existing `arb` database (this project does not create the database for you), then migrate and seed:
   ```bash
   php8.5 artisan migrate
   php8.5 artisan db:seed
   ```
   Or seed specific tables:
   ```bash
   php8.5 artisan db:seed --class=AllowedAdAccountSeeder
   php8.5 artisan db:seed --class=PlatformConnectionSeeder
   ```

4. **Storage & cache permissions**
   Laravel needs write access to `storage/` and `bootstrap/cache/`. On shared servers the PHP-FPM/web server user (e.g. `nginx`) is often **not** in your user's group, so `755`/`644` isn't enough — the web server can't read or write those paths. If you hit a blank/500 error, run:
   ```bash
   chmod -R 777 storage bootstrap/cache
   ```

5. **Clear stale caches**
   After pulling changes that touch config, routes, or providers, clear cached bootstrap files:
   ```bash
   php8.5 artisan optimize:clear
   ```

6. **Queue worker & token refresh (scheduler-driven)**
   Queued jobs (image/video generation, ad copy) and daily platform OAuth token refreshes only run when the scheduler fires — there's no `queue:work` daemon or Supervisor here. In local dev, run the scheduler in the foreground:
   ```bash
   php8.5 artisan schedule:work
   ```
   On a real server, add a cron entry instead:
   ```cron
   * * * * * cd /path/to/project && php8.5 artisan schedule:run >> /dev/null 2>&1
   ```
   See `routes/console.php` for the scheduled tasks (queue worker every minute, `platforms:refresh-tokens` daily, `campaigns:sync-performance` hourly/daily, and queue cleanup daily).

7. **Verify**
   Visit the app URL in your browser. If it 500s, check `storage/logs/laravel.log` first — with `APP_DEBUG=true` in `.env`, Laravel also renders the error directly in the browser.

## Platform Connections & Guardrails

Ad platform access, allowlists, and credentials are backed by the database with transparent `.env` fallback.

### 1. Allowed Ad Accounts (`allowed_ad_accounts`)
- Guardrail table enforcing which ad accounts the application may reach (reads and writes).
- One platform can have multiple distinct allowed ad accounts (e.g. separate accounts for Search, Retargeting, and Brand).
- **Precedence**: When migrated and populated, the `allowed_ad_accounts` database table takes strict precedence over `PLATFORM_ALLOWED_AD_ACCOUNTS` in `.env`. Editing `.env` alone will **not** update allowlists if the table contains entries.
- **Seeding / Syncing**: To sync `.env` entries into the database on arb-dev or production, run `php8.5 artisan db:seed --class=AllowedAdAccountSeeder`.
- Check in PHP: `AllowedAdAccount::isAllowed($accountId, $platform)` or through `Guardrails::allows($accountId, $platform)`.

### 2. Platform Connections (`platform_connections`)
- Stores account OAuth access tokens, refresh tokens, and account-specific secrets.
- **Storage**: `platform_data` holds non-secret identifiers such as organization, ad account, client, and manager IDs as readable JSON. `access_token`, `refresh_token`, and `platform_secrets` (for optional account-specific app secrets, developer tokens, and MCP tokens) are encrypted and hidden from serialization. The migration converts older encrypted `platform_data` rows and fails before changing rows if the current `APP_KEY` cannot decrypt them.
- **Configuration**: The connection seeder copies configured values into new shared connections and fills only missing fields on existing connections; it preserves stored tokens and nonempty fields. It retains the temporary hardcoded fallbacks for Meta and LinkedIn, plus Google's developer credentials, until the account connection UI exists. Google needs a configured refresh token to create its connection. Runtime API calls use stored credentials first and config/`.env` as fallback, so the shared connections continue working after those `.env` values are removed. Keep `APP_KEY` stable so encrypted credentials remain readable.
- **User Tenancy**: `user_id` is nullable (shared system connection) for automated background flows, and supports authenticated users when direct UI account connection is activated (Phase 2).
- Initially populated via `php8.5 artisan db:seed --class=PlatformConnectionSeeder` when platform tokens are configured.

### 3. Automated Token Refreshing
Most ad platform user tokens expire every 60 days (e.g. Meta long-lived user tokens, LinkedIn access tokens):
- **Command**: `php8.5 artisan platforms:refresh-tokens --days=10`
  - Scans active connections whose access tokens expire within the threshold (default 10 days) and requests new tokens from the platform's OAuth endpoint.
  - Can target a specific connection: `php8.5 artisan platforms:refresh-tokens --connection=1`
- **Scheduler**: Automatically registered in `routes/console.php` to run daily.

---

## Helpful Commands Cheat Sheet

### Environment & Composer
Always run Composer via `php8.5 /usr/bin/composer` to ensure compatibility with PHP 8.5 on this system:
```bash
# Install PHP dependencies
php8.5 /usr/bin/composer install

# Update dependencies or check installed versions
php8.5 /usr/bin/composer show --direct
```

### Database & Migrations
```bash
# Run pending migrations
php8.5 artisan migrate

# Check migration status
php8.5 artisan migrate:status

# Seed allowed accounts and platform connections
php8.5 artisan db:seed --class=AllowedAdAccountSeeder
php8.5 artisan db:seed --class=PlatformConnectionSeeder

# Run full database seeder
php8.5 artisan db:seed
```

### Platform Connections & Token Management
```bash
# Refresh platform tokens expiring within 10 days
php8.5 artisan platforms:refresh-tokens

# Refresh platform tokens expiring within N days (e.g. 15 days)
php8.5 artisan platforms:refresh-tokens --days=15

# Force refresh for a specific connection ID
php8.5 artisan platforms:refresh-tokens --connection=1
```

### Scheduler & Queues
```bash
# List all registered scheduled tasks (queue worker & token refresher)
php8.5 artisan schedule:list

# Run scheduler locally in foreground (runs queue worker every minute and daily token refresh)
php8.5 artisan schedule:work

# Manually process one batch of queued jobs
php8.5 artisan queue:work --queue=default --stop-when-empty --max-time=50
```

### Cache & Permissions
```bash
# Clear all application, route, config, and view caches
php8.5 artisan optimize:clear

# Fix permissions on storage and bootstrap cache
chmod -R 777 storage bootstrap/cache
```

### Testing & Code Quality
```bash
# Run entire PHPUnit test suite
php8.5 vendor/bin/phpunit
# or:
php8.5 artisan test

# Run a specific test class
php8.5 vendor/bin/phpunit tests/Feature/PlatformConnectionTest.php
php8.5 vendor/bin/phpunit tests/Feature/AllowedAdAccountDatabaseTest.php

# Run targeted test filter
php8.5 vendor/bin/phpunit --filter "PlatformConnection|AllowedAdAccount"

# Format dirty files using Laravel Pint
php8.5 vendor/bin/pint --dirty --format agent

# Format all project files
php8.5 vendor/bin/pint --format agent
```

### Interactive Debugging (Tinker)
```bash
# Inspect platform connections in Tinker
php8.5 artisan tinker --execute 'dump(\App\Models\PlatformConnection::all());'

# Inspect allowed ad accounts in Tinker
php8.5 artisan tinker --execute 'dump(\App\Models\AllowedAdAccount::all());'
```

---

## Agentic Development

Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow:

```bash
php8.5 /usr/bin/composer require laravel/boost --dev

php8.5 artisan boost:install
```

Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices.

## Testing

```bash
php8.5 /usr/bin/composer test
# or directly:
php8.5 artisan test
```

`phpunit.xml` overrides `APP_URL=http://localhost` for the test environment — the real `.env`'s `APP_URL` includes a shared-hosting subpath that breaks relative-path resolution in HTTP feature tests, so don't remove that override. `laravel/ai` fakes (`Image::fake()`, agent fakes) are used to test AI-backed jobs without hitting real providers; do the same for any new `Services/AI` or `Services/Platforms` code — never call a real external API from a test.

## Multi-platform targeting roadmap

**Status: Phase 1 implemented and seeded in the development database (249 countries), October 1, 2026.** Live Meta, LinkedIn, and Google lookups returned exact US, GB, IN, and CA matches; all twelve mappings are saved as verified. Phase 2 storage and Meta/Google draft city/region search, selection, review, and create-time translation are implemented. Meta and Google import baselines retain geographic criteria. LinkedIn city/region selection and live location edits remain unavailable, so Phase 2 is not yet fully complete. Other environments must migrate and seed independently.

### Current behavior and gaps

- [config/countries.php](config/countries.php) contains 249 ISO 3166-1 countries and territories, extracted from the system's `iso-codes` dataset. It is a committed English-name snapshot, searchable by name or two-letter code, with no runtime country API dependency.
- [SetTargeting](app/Agent/Tools/Campaign/SetTargeting.php) validates and saves country codes. [Brief](app/Models/Brief.php) stores shared location intent; [Campaign](app/Models/Campaign.php) stores platform overrides. A campaign can also be imported without a brief.
- The picker lists ISO countries, not a guarantee that every advertising platform supports every entry. [CountryCatalog](app/Campaigns/Targeting/CountryCatalog.php) resolves Meta, Google Ads, and LinkedIn selections against provider country lookup endpoints and caches verified identifiers for 30 days. Unknown or stale mappings require an exact provider match; unresolved countries block selection or publication without broadening targeting. The Google and LinkedIn hardcoded publisher maps have been removed.
- Meta draft campaigns can search cities or states/regions through `campaign__choose_location` after setting a country and verifying an ad account. The search offers provider IDs with full location labels, supports four pages of 25 matches, rechecks a chosen ID, and stores the selected mapping in `targeting_locations`. Repeating the tool without a query lists selected places for removal. The campaign review panel shows selected places. A selected city/region replaces whole-country targeting for its country in the Meta ad-set payload; other selected countries remain whole-country. Stale, mismatched, unsupported, or unresolved selections block publication. [Meta's official SDK example](https://github.com/facebook/facebook-python-business-sdk/blob/main/examples/AdAccountAdSetsPostDemographicTargeting.py) shows city and region keys in `geo_locations`; this project's new key-only city payload still needs a live provider validation before production use.
- Google draft campaigns use the same chat tool and saved selections. Ambiguous Google names can be refined as `City, State` or `City, Province`; the query checks Google's canonical location name and still rechecks the exact selected resource name before saving. Google has no paged search here, so only the first 25 results of a query are offered; refine the query if the desired place is absent. Publishing sends selected city/state `geoTargetConstants/...` resources as campaign location criteria and omits the whole-country criterion for that country, while retaining other whole-country selections. Google documents [geo-target resource names and campaign location criteria](https://developers.google.com/google-ads/api/docs/targeting/location-targeting) and [filterable canonical names](https://developers.google.com/google-ads/api/fields/v25/geo_target_constant).
- Imported Meta geography, including cities, regions, custom locations, and other returned `geo_locations` options, is retained in the campaign's `meta_geo_locations` JSON and published baseline. The `open_campaign` tool can also adopt a Google campaign by explicit ID with `platform: google` from the configured, allowed customer account. It records included and excluded Google location criterion IDs and country labels in `google_location_criteria` and the published baseline. An incomplete location response or proximity/location-group criterion refuses import rather than displaying an incomplete audience. An unrelated edit leaves the imported criteria untouched. Imported Google budget edits require a known, unshared budget resource. Changing location targeting on a published campaign is still refused because this app has no approved in-place targeting update path. The DMA, radius, and audience-estimate workflow described below remains future work.
- Google Search publishing currently derives keywords from landing-page analysis. An existing [KeywordResearchService](app/Contracts/KeywordResearchService.php) and [Google implementation](app/Services/Keywords/GoogleAdsKeywordResearchService.php) can be extended. The latter currently discards its `geoTarget` argument; geographic research must be corrected before exposing location-specific keyword results.
- Imports, live edits, validation, and publishing must evolve together. Supporting a field in the picker alone is insufficient; an unrelated edit must not erase targeting already present on a live campaign.

### Storage decision: tables, API lookups, and limited JSON

Use database tables for reusable locations, platform identifiers, and saved targeting selections. Query platform APIs for targetable entities and estimates, with local caching. Keep JSON for validated platform-specific options and versioned snapshots. Do not maintain growing static files of cities, DMAs, keywords, or audience sizes.

The country config remains the seed/fallback source for country labels. It cannot establish platform eligibility. [CountryLocationSeeder](database/seeders/CountryLocationSeeder.php) seeds countries idempotently; populate other locations from verified searches and supported bulk datasets as needed. A server must not depend on `/usr/share/iso-codes` being installed at runtime. After migrating, run `php8.5 artisan db:seed --class=CountryLocationSeeder --no-interaction` (or the normal `DatabaseSeeder`, which calls it). The picker can still use the committed config before seeding, but the tables must exist.

**Source choice informed by AdCenter:** Its `Admin.ads_countries` supplies country names/codes; `Admin.geo_targeting` supplies US states and searchable US cities; its campaign form reads DMA labels/codes from `html/app/Config/dma.json`. Selections are stored in `campaign_targeting.json`. Its separate social campaign picker queries Meta's location search. These are observations from AdCenter's code, not a verified inventory of its live database. Our app should own its catalog and import a reviewed snapshot only if data access, provenance, and freshness are confirmed; it should not depend on AdCenter's database at request time.

| List | Recommended source for this app | How to serve it |
|---|---|---|
| Countries | Seed the local catalog from committed ISO country names/codes. | Read locally; show per-platform eligibility from verified provider IDs. An ISO entry alone is not proof of ad-platform support. |
| States/regions | Optionally bootstrap US labels from a reviewed AdCenter `geo_targeting` export; discover worldwide targetable regions from each platform's lookup API. | Search locally and refresh provider records as needed. Save country and parent context to disambiguate names. |
| Cities | Optionally bootstrap the reviewed US subset; use platform lookups for global coverage. | Search server-side with country/region filters and pagination. Cache and persist selected provider entities rather than preloading every city. |
| DMAs/markets | AdCenter's bundled DMA file can supply initial US display labels after review. Verify each selectable market against each provider. | Store market type and provider ID; present only verified targetable markets on that platform. Never assume a legacy DMA code equals a Meta, Google, or LinkedIn location ID. |

The first useful deliverable is a country catalog and provider mappings. Add state/city search next; add market/DMA selection only after a platform-specific lookup confirms its identifiers. This keeps the existing country picker useful while avoiding a full worldwide city import or a permanent dependency on the older AdCenter application.

Logical schema (Phase 1 tables are implemented; later rows are proposed):

| Store | Purpose and principal fields |
|---|---|
| `locations` | Implemented: internal geographic identity with `id`, `type`, `name`, `country_code`, nullable `iso_code` and `parent_id`, `source`, and `source_key`. Countries are seeded; later phases will add other types and any needed coordinates. |
| `platform_locations` | Implemented: provider `platform`, `external_id`, `location_id`, `provider_name`, mapping `status`, and `verified_at`. Country IDs are recorded only after exact provider lookup. Further provider metadata and multiple mappings per internal location need later schema work. |
| `targeting_sets` | Versioned user selections: exactly one owning `brief_id` or `campaign_id`, targeting scope, explicit inheritance/override state, revision, schema version, and validated options JSON. Campaign sets contain platform-specific choices; brief sets describe shared intent. |
| `targeting_locations` | A selection belonging to a targeting set: include/exclude operation, location kind, internal location reference and/or verified platform reference. Radius entries store latitude, longitude, distance, and unit instead of inventing a geographic ID. Retain the selected display label for review. |
| `targeting_keywords` | Keyword selections belonging to a platform targeting set: text, match type, negative flag, provenance, and external criterion reference where published. Enforce supported targeting scope and match types for that platform/campaign type. |
| Application cache | Short-lived location search results and audience/forecast results. Store request hash, result status, metric type, timestamp, and expiry. Add durable estimate history only if audit/reporting requirements justify it. |

Require unique provider identifiers within the platform/catalog namespace and indexes for country/type/name searches, parent references, and targeting-set ownership. Names are not unique IDs. Do not assume one internal location has exactly one provider entity per platform; boundaries and entity types may differ. Prefer status changes over deleting referenced catalog entries.

Treat scope explicitly: geographic targeting can belong to a campaign, ad set, or supported ad group, while keywords can have different scopes. Initially bind to the app's existing single delivery group where applicable. Before enabling multiple ad sets/ad groups, introduce explicit local group ownership and foreign keys; do not attach all groups' selections to an undifferentiated campaign list.

### Location search and selection flow

1. Resolve the authenticated user's brief/campaign, selected platforms, account connections, and campaign types.
2. Search local records by country, type, and text. Debounce and paginate searches; never send an entire global city catalog to the browser.
3. On cache miss or stale catalog data, query the relevant provider through an application contract and normalize its response. Save provider IDs and verification metadata without calling publishing endpoints.
4. Show the full location context, such as city, state, and country, together with support status for each selected platform. Distinguish supported, unsupported, unresolved, and temporarily unavailable.
5. Save shared intent on the brief and explicit platform selections on the relevant campaign. If a match is ambiguous, ask the user to select it. Never match across platforms solely by name or substitute a larger area silently.
6. Validate again before publishing or editing a live campaign. Unsupported or unresolved selections block that platform's operation with an actionable message; other platform drafts remain editable.

For example, selecting a city for Meta and Google should preserve the shared city intent while saving each platform's verified target identifier. If LinkedIn has no confirmed equivalent, show that limitation and retain the selection as unresolved. Do not turn it into country-wide targeting.

### Capabilities and platform translation

Extend the existing rules under [app/Campaigns/Platforms](app/Campaigns/Platforms) with capabilities evaluated by platform, campaign type, account access, and relevant category restrictions. Describe allowed location types, include/exclude operations, radius units/limits, targeting scope, presence/interest behavior, keyword semantics, and estimate availability. An unknown capability remains unavailable until verified.

Use focused external-service contracts under `app/Contracts` for location search and audience estimation, with implementations alongside the existing platform services. Reuse the keyword research contract. Keep normalization and selection validation in the campaign domain, then pass validated targeting to the existing publishers. Chat tools and UI components should use the same validation path.

| Platform | Integration direction |
|---|---|
| Google Ads | Resolve supported locations to geo-target criterion IDs using the geographic catalog/search service. Translate radius selections to proximity targeting where supported. Keep Search keywords, negative keywords, audience signals, and forecast metrics distinct. |
| Meta | Resolve geography through targeting search and build the correct ad-set geographic include/exclude payload. Check radius and market support against the configured API/account/category context. Use account-context estimate endpoints where available. |
| LinkedIn | Resolve targeting entities to provider URNs and build targeting facets. Enable only verified geographic types and operations; do not assume arbitrary radius or DMA parity. Use audience counts where access and targeting criteria permit. |

DMA means Designated Market Area. Preserve the provider's market identifier, country, and type; a similarly named metro area is not automatically the same boundary. Support market targeting only where the provider exposes the required entity and the campaign permits it.

Google's documentation describes geographic criterion IDs and proximity targeting in its [location targeting guide](https://developers.google.com/google-ads/api/docs/targeting/location-targeting). LinkedIn documents its targeting entities and facets in [Ad Targeting](https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads/advertising-targeting/ads-targeting). Meta's published [targeting and reach integration catalog](https://github.com/facebookincubator/catalogue-of-api-solutions/blob/main/solutions/miscellaneous/targeting-reach-estimate.md) identifies geography search and estimate operations. Verify these against the project's configured API versions and actual account permissions during implementation; these references do not establish universal feature support.

### Radius, keywords, and audience estimates

**Radius:** Save a confirmed center point, distance, and explicit `km`/`mi` unit. Validate coordinate ranges, distance limits, inclusion/exclusion support, and platform eligibility. Never infer radius from a city's center when the user selected its boundary. Address-to-coordinate lookup is a separate capability; the first implementation can accept a confirmed map point/coordinates. Choose an address provider separately if needed, without adding an implicit dependency.

**Keywords:** Reuse existing research services for suggestions, but save only the user's selected terms as targeting. Respect account, location, language, network, match type, negative-keyword support, and delivery scope. A Google Search keyword is not equivalent to a Meta interest or a LinkedIn skill. Research suggestions, search volume, and published criteria must remain distinguishable. Google's [Keyword Planning overview](https://developers.google.com/google-ads/api/docs/keyword-planning/overview) separates ideas, historical metrics, and forecasts.

**Estimates:** Request estimates for the complete resolved targeting specification. Cache by authenticated account context, platform/API version, campaign type/objective, normalized targeting revision, and every additional input relevant to that endpoint, such as budget, dates, language, or placements. Begin with a configurable short expiry, for example 15 minutes, subject to provider requirements. Changing an input invalidates the prior estimate.

Display the provider's metric name, units/range, and calculation time. Distinguish unavailable, stale, error, suppressed, and an actual zero result. A Search traffic forecast is not an audience headcount, and platform estimates must not be summed into a claimed count of unique people. An optional estimate failure need not block a valid draft or publication; unresolved targeting does. LinkedIn's [Audience Counts API](https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads/advertising-targeting/audience-counts) reports members matching targeting criteria; Meta provides an official [reach estimate SDK example](https://github.com/facebook/facebook-php-business-sdk/blob/main/examples/AdAccountReachEstimate.php).

### Implementation phases and acceptance criteria

Phase 1 code and focused tests are implemented, and the development database is migrated and seeded. Meta, LinkedIn, and Google live country lookups passed for US, GB, IN, and CA. The Phase 2 targeting tables are also migrated and existing country selections backfilled in development. `targeting:backfill` is repeatable and leaves legacy campaign JSON unchanged. New country edits write both versioned selections and a legacy projection. `LocationDiscovery` searches Meta and Google for targetable cities and regions, and rechecks a selected provider ID before storing it. [LinkedIn's typeahead documentation](https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads/advertising-targeting/ads-targeting) exposes location names and URNs but does not establish a reliable city/state kind for mixed results, so LinkedIn city/region selection remains unavailable. Meta and Google draft selection is enabled through chat; publication translates verified locations into platform criteria, includes targeting in the approval snapshot, and blocks stale selections. Meta and Google imported geography is preserved in the published baseline; Google import rejects geography this phase cannot represent. Published location edits remain rejected. LinkedIn type verification, in-place location updates, and live provider validation of Meta's key-only city payload remain open.

On another environment, run `php8.5 artisan migrate --force --no-interaction`, seed countries if needed with `php8.5 artisan db:seed --class=CountryLocationSeeder --force --no-interaction`, then run `php8.5 artisan targeting:backfill --no-interaction`. Backfill does not call provider APIs or alter legacy campaign JSON.

| Phase | Work | Done when |
|---|---|---|
| 0. Verify integration boundaries | Inspect live schema read-only, installed SDK/configured API versions, existing imports/updates, and account access. Document a capability matrix for the initial three platforms and settle delivery-group ownership. | Every proposed control has a known publishing scope, provider lookup strategy, and supported/unsupported/unknown state. Any permission or provider dependency is explicit. |
| 1. Country catalog and mappings | Create `locations` and `platform_locations`, factories, and an idempotent country seeder. Implement provider country resolution, cache/refresh behavior, and availability feedback in the existing picker. Replace hardcoded publisher mappings through the shared resolver. | Every selected country resolves to a verified provider ID or a clear unavailable state. Existing supported campaigns produce equivalent payloads; missing mappings never broaden targeting. |
| 2. Saved targeting and cities/states | Add targeting sets/selections and a compatibility reader for existing country JSON. Build searchable city/state selection, shared brief intent, platform overrides, revision checks, and create/update/import translations. | Ambiguous names require selection; platform IDs survive reloads; changing a country does not erase other criteria; supported publish/edit/import paths preserve selections. |
| 3. Markets, radius, and exclusions | Add verified market/DMA entries, center/radius controls, geographic exclusions, and presence/interest options where supported. | Units, boundaries, include/exclude semantics, and restrictions are validated per platform. Unsupported controls explain their status and cannot be submitted. |
| 4. Keyword selection | Add keyword records, extend the existing research service with actual geographic/account/language context, and connect approved selections to publication and edits. | Selected terms, match types, and negatives round-trip at the correct scope. Research respects selected geography; unsupported campaign types receive no Search keyword payload. |
| 5. Audience and forecast panel | Add provider estimate adapters, input-based cache invalidation, async status updates, and clearly labeled metrics. | Estimates reflect the current selection, stale responses cannot replace newer results, account caches are isolated, and failures remain distinct from zero. |
| 6. Rollout and retirement | Backfill compatible legacy drafts, compare old/new payloads, enable each platform incrementally, monitor errors, and retire legacy mappings/read paths only after parity. | Migrated drafts and adopted campaigns retain their meaning; rollback has been exercised; supported create/update/import flows pass regression tests. |

Phase 1 is the first implementation milestone. The later phases depend on the shared resolver and targeting model; they should not be implemented as independent picker-only features. Phase 5 can move ahead of Phase 4 if country/city audience sizing becomes the immediate product priority.

### Compatibility, publishing, and rollback

- Read legacy `briefs.locations` and `campaigns.locations` through a compatibility layer. Preserve campaign overrides and imported campaigns with no brief. Represent inheritance explicitly rather than treating an intentionally cleared selection as permission to restore a brief value.
- Backfill in resumable, idempotent batches. Preserve original data and provenance; do not trigger remote changes as part of migration. Historical unsupported selections remain visible with an unresolved status.
- Establish one authoritative write path per migrated record. Any legacy country projection is derived and updated together with the new selection. Extend partial updates so changing countries cannot overwrite cities, exclusions, or radius settings.
- Capture campaign ID, account ID, targeting revision, resolved IDs, and normalized payload when reviewing a publish/edit action. Changing those inputs requires a fresh review. Revalidate before remote mutation and preserve the existing paused/draft publication behavior.
- Include targeting in published snapshots and change comparisons. Imported provider criteria that cannot yet be represented remain preserved; block edits that would accidentally discard them. Cover all supported publish/update transports with the same translation rules.
- Roll out with feature switches by platform and feature. Keep legacy reads until parity is demonstrated. Rolling back must disable editing/publishing of advanced selections that old code cannot represent, rather than reducing them to country-only targeting. Retain the additive tables and saved data during rollback.

### Operations and validation

Use the existing database-backed cache, queue, and scheduler initially. Public catalog records may be shared, while saved selections, account eligibility checks, and estimates must respect user/account authorization and existing account allowlists. Never persist credentials in catalog metadata or cache keys.

Apply bounded timeouts, retries only for retryable read failures, provider rate limits, and refresh coordination. Provisional defaults are a 24-hour location-search cache and a 15-minute estimate cache; tune per provider requirements. Keep long-lived saved IDs independently of cache expiry, refresh their status, and block stale/unverified selections when current validity cannot be established. Record lookup latency, cache hits, unresolved mappings, rate-limit responses, and publish validation failures without logging secrets. No new infrastructure or dependency is required by this plan.

The implementation test plan must cover:

- Repeatable country seeding; duplicate names; region hierarchy; multiple provider entities; retired or unresolved mappings.
- Existing country-only records; brief inheritance; explicit overrides/clearing; adopted campaigns without briefs; resumable backfills.
- Exact supported provider payloads for include/exclude, radius, markets, presence options, and keyword scope; rejected unsupported combinations before mutation.
- Publish, live edit, import, and unrelated-edit round trips without dropping provider criteria; targeting/account revisions invalidating old review snapshots.
- Authorization and account allowlists; account-isolated caches; provider timeouts, pagination, rate limits, and unavailable estimates.
- Browser search, ambiguous-location selection, persistence, platform status feedback, unit controls, and late async responses after the user changes targeting.

Use fake provider responses in automated tests. Run the narrow affected tests per phase and the relevant chat/publishing regressions before rollout. Read-only integration checks with configured accounts verify actual permissions and API compatibility; automated tests must not create live campaigns.

Before enabling Phase 1 for a connected account, use the read-only `CountryLookup::find()` method for representative countries (US, GB, IN, and CA) on each platform, passing an allowed account ID. Confirm the returned identifier and name refer to the selected country, especially Canada on LinkedIn. This makes provider GET/search requests and records them in `api_calls`, but does not create a campaign or save a mapping. Then test country selection in a draft through the picker; that path saves verified mappings to `platform_locations`. An unsupported or unavailable country must leave the draft unchanged and prevent publication.

## Further Reading

- `docs/TICKETS.md` — full product spec (SERP-1827)
- `docs/SERP-1827-PLAN.md` — architecture and phased build plan, with phase completion status
- `docs/TASKS.md` — longer-term task backlog beyond this ticket
