# Campaign Management — Frontend Implementation Guide

Multi-channel (SMS / RCS / WhatsApp) marketing campaign system. A campaign sends a message, built from a
provider-approved template, to a list of recipients, through a Bluewaves **Journey** — a provider-side,
ordered set of channels (in `priority` order) with per-channel fallback timing, registered once per campaign
and reused for every recipient send. Cross-channel failover is handled by the provider, not by this
application (see §8, §12.1) — `strategy` (`failover`/`parallel`) is still collected per campaign and passed
through as the Journey's `mode`, but this codebase no longer loops channels/does its own retry logic itself.

This doc covers the full flow a frontend needs to implement: credentials setup → template selection →
campaign creation → audience → variables → preview → launch → monitoring.

All endpoints follow the project-wide conventions in [`api.md`](api.md): `POST` verb-path routes, Sanctum
bearer auth, and the `{success, message, data, errors}` response envelope. Every route below sits behind
`auth:sanctum` + `account_validation` middleware, plus an in-controller permission check.

---

## 0. One-time setup (admin/settings screens, not part of the campaign wizard)

Before any campaign can send anything, three provider credentials must be configured. These are simple
key/value settings screens — build them once, not per-campaign.

| Provider | Endpoint | Body | Permission |
|---|---|---|---|
| SMS | `POST /api/sms_credential/update` | `api_key` | `setting.sms_credential.update` |
| WhatsApp | `POST /api/waba_credential/update` | `api_key`, `phone_number_id`, `waba_business_id` | `setting.waba_credential.update` |
| RCS | `POST /api/rcs_credential/update` | `api_key` | `setting.rcs_credential.update` |
| Campaign API (Journey) | *(no settings endpoint yet — set directly in `campaign_api_credentials`)* | `api_key` | — |

Fetch the current value with the matching `/fetch` endpoint on each prefix (except Campaign API, below).

**Important:** these are single, account-wide rows (one credential set per channel for the whole system),
not per-campaign. Get them right once in Settings.

**Campaign API credential (`campaign_api_credentials`)** is a fourth, separate single-row credential — the
Bearer token used for `journey/create` and the `channel: "Journey"` `/api/v1/send` calls that every campaign
send now goes through (§8). It is intentionally distinct from the three per-channel keys above: those keys
predate the Journey API and were each scoped to a single channel, whereas a Journey call spans multiple
channels in one request and needs one token with access to all of them. There is no settings UI wired up for
it yet — it must be seeded directly in the `campaign_api_credentials` table until one is built.

**`waba_business_id`** is a new field on the WhatsApp credential, required for the `wabaBusinessId` field
Journey's WhatsApp components need (§2.2) — it wasn't previously stored since the old direct-to-Meta-Cloud-API
sends didn't need it.

---

## 1. Templates — fetch from provider, then pick per campaign

Templates live in three separate local tables (`sms_templates`, `rcs_templates`, `waba_templates`), each a
local cache of what's registered with the provider (Bluewaves Media / Meta). **There is no "list all
templates on the provider" endpoint** — you must already know a template's exact name/ID to pull it in.

### 1.1 List locally cached templates (for a template picker UI)

| Channel | Endpoint | Filters |
|---|---|---|
| SMS | `POST /api/sms_template/fetch` | `category`, `state`, `language`, `name`, `limit` |
| RCS | `POST /api/rcs_template/fetch` | same |
| WhatsApp | `POST /api/waba_template/fetch` | same |

Permission: `marketing_management.templates.read` (or `.override`). Returns a paginated list
(`total_count`, `page`, `limit`, `total_pages`, `templates[]`).

### 1.2 Pull a new/updated template from the provider (upserts into the local table)

| Channel | Endpoint | Required body |
|---|---|---|
| SMS | `POST /api/sms_template/fetch_from_provider` | `sender_id`, `template_name` |
| RCS | `POST /api/rcs_template/fetch_from_provider` | `template_name` (`bot_id` comes from the stored RCS credential, not the request) |
| WhatsApp | `POST /api/waba_template/fetch_from_provider` | `version` (e.g. `"v23.0"`), `template_id` |

This is how you build a "search/import template" screen: user types the exact name/ID they registered on
the provider portal, you call this endpoint, and the template (with its full `raw_body`) is cached locally
and immediately available for campaign creation. Re-calling it with the same ID just refreshes the cached
copy (safe/idempotent — matched by `template_id`).

### 1.3 Reading a template's structure (what the picker needs to show the user)

Each template's `raw_body` is provider-specific JSON. The frontend needs to parse it to show a preview and
to know which variables the user must fill in:

- **SMS** (`raw_body.templateText`): plain string. Usually no `{{n}}` placeholders (DLT templates are often
  fixed text), but some do have them.
- **RCS** (`raw_body.Cards[]`): one or more cards, each with `CardTitle`, `CardDescription` (may contain
  `{{1}}`-style placeholders), `CardMedia.MediaUrl`, and `Suggestions[]` (buttons). **The `{{n}}` tokens
  are positional, not the actual variable identity** — each card also carries `TitleVariables`/
  `DescVariables`, arrays of `{"{{1}}": "Discount"}`-shaped objects that map the positional token to a
  *named* variable. That name (`"Discount"`, not `"1"`) is what mapping (§5) keys on, and what
  `resolveVariableValue()` looks recipient values up by — see
  `TemplateVariableService::extractRcsVariables()`, which walks these maps rather than regexing the card
  text. The actual Journey `/send` node payload re-keys each resolved value back to its `{{N}}` positional
  placeholder rather than the name (`extractRcsVariablePositions()`, §12.1) — the provider's send API wants
  position, not name, there. The `/preview` endpoint's `variables_used` for an `rcs` channel already
  returns these resolved names, so the frontend doesn't need to parse `Cards[]` itself to build the mapping
  form — call `/preview` (or after `create`, before recipients exist, still returns `variables_used` from
  the template alone) and use that list directly.
- **WhatsApp** (`raw_body[]`): an array of components, each with a `type`:
  - `HEADER` — has a `format`: `IMAGE`, `VIDEO`, `DOCUMENT`, `TEXT`, or `LOCATION`. For media formats, the
    `example.header_handle[0]` is a **preview-only** CDN URL — see §3.4, it cannot be reused for real sends.
  - `BODY` — `text` field contains the `{{1}}`, `{{2}}`... placeholders.
  - `BUTTONS` — static, not editable per-send.

Extract `{{n}}` placeholders from the body text client-side (regex `\{\{\s*(\w+)\s*\}\}`) to render a "fill
in these variables" form during campaign setup (see §5).

---

## 2. Creating a campaign

`POST /api/campaign/create` — permission `marketing_management.campaign.create` (or `.override`).

```json
{
  "name": "Weekend Promo",
  "strategy": "parallel",
  "channels": [
    {
      "type": "sms",
      "priority": 1,
      "template_id": "1777178419100483267",
      "sender_id": "SUTRAA",
      "fallback_time": 60,
      "credentials_config": {
        "dlt_template_id": "1777178419100483267",
        "peid": "1001585440000016245",
        "chain_value": "1001585440000016245,140200000077"
      }
    },
    {
      "type": "rcs",
      "priority": 2,
      "template_id": "product_carousel",
      "sender_id": "WkCZgRta8NlNaZtf"
    },
    {
      "type": "whatsapp",
      "priority": 3,
      "template_id": "1198781789105451",
      "credentials_config": {
        "header_image_link": "https://your-cdn.example.com/promo.jpg"
      }
    }
  ]
}
```

Note there's no `audience` here — audience selection is a separate step (§2.3) done *after* creation, same as
Excel upload always was. A large lead group can take a noticeable amount of time to import (see §2.3), so it
doesn't share a request/transaction with creating the campaign shell.

### 2.1 `strategy`

Passed through as-is to the Journey's top-level `mode` field at launch (§8) — the provider now owns the
actual failover/parallel execution, this app no longer implements it. Exact accepted `mode` values aren't in
the provider's published API reference; `"failover"`/`"parallel"` are what we currently send, on the
assumption they map to the same concepts. **Verify this against Bluewaves during UAT** before relying on it
in production — see §12.1.

### 2.2 `channels[]` — per-channel config the frontend must collect

| Field | Required | Notes |
|---|---|---|
| `type` | yes | `sms`, `rcs`, or `whatsapp` |
| `priority` | yes | integer ≥ 1. Determines both `campaign_channels.priority` ordering and the Journey component/node key (`"1"`, `"2"`, ...) |
| `template_id` | yes | the template's **provider** ID string (`template_id` column from §1, not the local row `id`) |
| `sender_id` | no | **Critical per channel — see table below** |
| `credentials_config` | no | free-form JSON object; per-channel overrides (see below) |
| `fallback_time` | no | **minutes** the Journey waits on this channel before falling back to the next one in priority order. Must be one of `1, 15, 30, 60, 120, 240` — Bluewaves rejects any other value with `"Fallback time must be one of..."`. Defaults to `services.campaign_journey.default_fallback_time` (60min) when omitted |

**`sender_id` / `credentials_config` requirements discovered per channel — the template record alone does
not carry these, so your campaign-creation form must expose fields for them:**

| Channel | `sender_id` meaning | `credentials_config` keys |
|---|---|---|
| SMS | DLT-registered sender ID (e.g. `"SUTRAA"`) — **required**, sending fails silently (HTTP 404) if wrong/missing | `dlt_template_id`, `peid`, `chain_value` — fall back to the template's own DB columns if omitted, but those are frequently blank, so provide them explicitly |
| RCS | Not used for sending — the AGENTID is pulled from `RcsCredential` (account-level `bot_id`, same row `RcsTemplateService` uses to fetch templates from the provider), **not** this per-channel field. Provider rejects with "AGENTID is required" if that credential row has no `bot_id` configured | none needed today |
| WhatsApp | Not used for sending | `header_image_link` (or `header_video_link` / `header_document_link` for those header formats) — **required if the template has a media header**, see §3.4. Optional: `header_document_filename`, `phone_number_id` (channel-level override of the account default), `version`, `language_code` |

### 2.3 Audience — its own step, after creation

Three supported sources — **there is no "manually type one recipient" endpoint that adds to an
existing audience**; each source below replaces whatever audience the campaign already had:

- **`POST /api/campaign/select_lead_group`** — permission `.update`/`.override`. Body: `campaign_id`,
  `lead_group_id` (`null`/omitted to clear the campaign back to no lead-group audience). Campaign must be
  `draft`. References a **Lead Group** (§1.5), a reusable, pre-built list of leads — **this is the only way
  to select a lead-based audience**; build the group once in the Leads module, then reuse it across
  campaigns. Response: `{ "imported_count": <int>, "total_recipients": <int> }`.
- Excel upload — see §4. Use this for ad-hoc lists not already in `leads`.
- **`POST /api/campaign/select_test_audience`** — permission `.update`/`.override`. Body: `campaign_id`,
  `test_numbers` (required array of raw phone numbers, 1-100, each matching `/^[0-9]{10,15}$/` — digits only,
  10-15 long, so either a plain national number or one prefixed with any country code; no `+`, spaces, or
  dashes allowed). Campaign
  must be `draft`. For sending a quick test round to a manually-typed handful of numbers before committing
  to a real lead group or Excel file — no lookup against `leads` happens, each number becomes a
  `CampaignRecipient` with `source_type = 'test'` and `recipient_data = {"phone": "<number>"}` (so it won't
  have any lead/company variables to map — see §5). Response: `{ "imported_count": <int>, "total_recipients":
  <int> }`. Calling it again replaces the previous test list outright.

Like Excel, these are **dedicated endpoints, not part of `create`/`update`** — importing a large group's
leads as recipients is done synchronously within this one request (batched, chunked — see the "how this
scales" note in §4), so audience selection is kept out of campaign creation/editing so those stay fast
regardless of audience size. Every matching lead with a phone number becomes a `CampaignRecipient`.
Audience is always exclusively lead-group-, Excel-, or test-sourced: calling any one of
`select_lead_group`, `upload_excel` (§4), or `select_test_audience` wipes whichever of the other two sources
the campaign previously had (and their recipients).

### 1.5 Lead Groups — reusable saved audiences (built in the Leads module)

Lead Groups are a **standalone Lead Management resource**, not a campaign sub-object — build the group-
management UI (create, browse, edit membership) as part of the Leads tab, and only reference an existing
group's `id` when building a campaign's audience (§2.3).

**Membership is always a fixed snapshot** taken at creation time — either from an explicit `lead_ids`
array (multi-select in the Leads tab), or, via `create-from-filters`, from whichever leads match a
server-side filter set at the moment of the call. Either way the resolved lead IDs are stored once in the
`lead_group_leads` pivot table. The group does **not** automatically pick up leads created/matching later —
add them explicitly (see below). This keeps a campaign's audience stable and predictable between building
it and launching it.

| Endpoint | Body | Purpose |
|---|---|---|
| `POST /api/lead/group/create` | `name`, `description?`, `company_id?`, `lead_ids` (required array) | Create a group from an explicit list of lead IDs |
| `POST /api/lead/group/preview-filters` | Any of the lead filter fields accepted by `/api/lead/all/fetch` (`search`, `source`, `priority`, `city`, `state`, `industry`, `call_status`, date ranges, etc.) | Read-only: returns `matching_leads_count` for the given filters, scoped the same way as `create-from-filters`. Call this first so the UI can show the resulting group size before the user commits |
| `POST /api/lead/group/create-from-filters` | `name`, `description?`, `company_id?`, plus any of the lead filter fields accepted by `/api/lead/all/fetch` (`search`, `source`, `priority`, `city`, `state`, `industry`, `call_status`, date ranges, etc.) | Create a group from every lead currently matching the given filters, scoped to what the caller can read (per-status raw/verified/client read-or-override permissions, same as `search_all_leads`). Errors 422 if no lead matches |
| `POST /api/lead/group/fetch` | `id?`, else `search`, `company_id`, `limit` | Get one group (with its `leads[]` and `members_count`) or a paginated list |
| `POST /api/lead/group/update` | `id`, `name?`, `description?` | Rename/redescribe only — does not touch membership |
| `POST /api/lead/group/members/add` | `id`, `lead_ids` (required array) | Adds more leads to an existing group (only genuinely new leads are added, safe to call repeatedly) |
| `POST /api/lead/group/members/remove` | `id`, `lead_ids` (required array) | Removes specific leads from the group |
| `POST /api/lead/group/delete` | `id` | Soft-deletes the group. **Campaigns that already used this group keep their already-imported recipients** — deleting a group never retroactively removes recipients from a campaign that already launched or was created from it |

Permission: `lead_management.lead_group.{create,read,update,delete,override}`.

### 2.4 Response

`CampaignResource` — includes `id`, `status` (`draft` right after creation), `total_recipients`,
`sent_count`, `failed_count`, `delivered_count`, `success_rate`, `delivery_rate`, and nested `channels`,
`audience`, `variable_mappings` when the relevant relations are loaded (see §7 `fetch`).

---

## 3. Updating a draft campaign

`POST /api/campaign/update` — permission `.update`/`.override`. **Only works while `status === "draft"`**
(the controller rejects with 400 otherwise). Body: `id` plus any of `name`, `strategy`, `channels[]`
(replaces all channels). Audience is **not** settable here — use `select_lead_group` (§2.3), `upload_excel`
(§4), or `select_test_audience` (§2.3) instead, same as at creation time.

---

## 4. Excel-based audience (single step: upload = import)

For ad-hoc recipient lists that aren't in the `leads` table.

**`POST /api/campaign/upload_excel`** — multipart form: `campaign_id`, `excel_file` (`.xlsx`/`.xls`/`.csv`).
Campaign must be `draft`. This stores the file, parses its header row, **and immediately imports every row
as a `CampaignRecipient`** — there is no separate import step (there used to be a `POST
/api/campaign/import_excel`; it's been removed because leaving upload and import as two calls made it easy
to upload a file, forget the second call, and have the campaign silently launch with 0 (or stale) Excel
recipients). The importer looks for a phone column named one of `phone`/`number`/`mobile`/`contact`/
`phone_number`/`contact_number`/`mobile_number` (case-insensitive, whitespace-trimmed) — make sure your
Excel template's header matches one of these, or rows without a match are silently skipped (an all-zero
`imported_count` in the response with a nonzero `row_count` is the symptom). Response includes `headers`,
`row_count` (rows in the file), `imported_count` (rows actually turned into recipients), and the campaign's
updated `total_recipients`.

Re-uploading a new file for the same campaign replaces the previous Excel import outright — old Excel (and
any lead-group or test) recipients for that campaign are deleted before the new file's rows are imported.

**Audience is always exclusively lead-group-, Excel-, or test-sourced — never more than one at once.**
`source_type` is never `'both'` in practice despite the DB enum still allowing it for legacy rows.
`upload_excel` always sets `audience.source_type = 'excel'` and clears `lead_group_id`/`test_numbers`; if the
audience previously had a lead group or test numbers, those `CampaignRecipient` rows are deleted as part of
the same call — you do **not** need to call `select_lead_group` (§2.3) or clear the test audience first
before uploading Excel. The same holds in every direction: `select_lead_group` with a `lead_group_id`
clears any existing Excel/test audience, and `select_test_audience` clears any existing lead-group/Excel
audience. This keeps a campaign's recipients exclusively single-sourced at launch.

**How this scales:** both `select_lead_group` and `upload_excel` insert recipients in batches of 500 rows
(`CampaignService::IMPORT_BATCH_SIZE` / `CampaignExcelImportService::IMPORT_BATCH_SIZE`) rather than one
`INSERT` per row, and `select_lead_group` eager-loads `primaryPhone`/`primaryEmail`/`company` while chunking
the lead-group query instead of loading the whole group into memory. Both also call `set_time_limit(0)`
for the request so a large group/file isn't killed by PHP's default 30-60s execution limit. They're still
synchronous, blocking calls, though — there's no background-job/polling audience import (yet); a very large
audience (tens of thousands+) will still mean a slow request.

Every other Excel column becomes a key in that recipient's `recipient_data` JSON — this is what variable
mapping (§5) reads from.

**For lead-sourced recipients** (via `lead_group_id`), `recipient_data` is built
automatically from every column on `leads` and `companies` except internal/system columns (ids, FKs,
`is_deleted`, timestamps, soft-delete column, `companies.logo`) — company columns are prefixed with
`company_` to avoid colliding with the lead's own columns of the same name (both have a `status`, for
example). This list is **not hardcoded** — it's derived from the live DB schema
(`Schema::getColumnListing('leads')` / `('companies')`) in `CampaignService::importLeadGroupRecipients()`, so
it changes automatically if a migration adds/removes a column, and never drifts out of sync with what's
actually in `recipient_data`.

`CampaignRecipient.country_code` is copied as-is from the lead/Excel row and is **inconsistently
formatted at the source** — sometimes `"91"`, sometimes `"+91"`, sometimes blank.
`CampaignRecipient::getFullPhoneAttribute()` (what actually gets sent as `to`/`destination` to
SMS/RCS/WhatsApp — see §6/§8) strips all non-digit characters from both `country_code` and `phone` before
concatenating, specifically so a stray `+` never lands mid-string (e.g. `"+917980116045"`, which providers
reject), and defaults `country_code` to `"91"` (India) when it's blank. Don't reintroduce raw concatenation
elsewhere in the codebase without the same normalization.

**`POST /api/campaign/mapping_fields`** — permission `.read`/`.override`. No body. Returns the same
schema-derived list your mapping UI should render as `column` options when the audience is lead/company
sourced (skip this call for Excel audiences — use the `headers` from `upload_excel` instead, §4 above):

```json
{
  "success": true,
  "message": "Mapping fields fetched successfully",
  "data": {
    "lead_fields": ["name", "email", "country_code", "phone", "source", "designation", "priority", "status", "is_interest", "interest_remark", "call_status", "is_call_back", "is_primary", "cnvt_to_verified_at", "cnvt_to_client_at"],
    "company_fields": ["company_name", "company_source", "company_website", "company_industry", "company_sub_industry", "company_state", "company_city", "company_address", "company_zipcode", "company_credit_limit", "company_credit_duration_days", "company_status"]
  }
}
```

Use these as `column` values when mapping variables (§5) — e.g. map `{{2}}` on a WhatsApp template to
`column: "company_name"` to greet the recipient's organization by name.

---

## 5. Variable mapping — filling `{{1}}`, `{{2}}`... in templates

`POST /api/campaign/map_variables` — permission `.update`/`.override`.

```json
{
  "campaign_id": 42,
  "mappings": [
    { "variable": "1", "channel_type": "whatsapp", "column": "first_name", "default_value": "there" },
    { "variable": "Discount", "channel_type": "rcs", "column": "discount_pct", "default_value": "10", "is_required": true },
    { "variable": "2", "channel_type": null, "column": "city" }
  ]
}
```

### 5.1 Why `channel_type` matters — read this before building the mapping UI

**Different channels' templates frequently reuse the same variable name** (e.g. both an RCS template and a
WhatsApp template might use `{{1}}` for completely different content — a discount % vs. a greeting). A
mapping is resolved **per channel**:

- `channel_type: "sms" | "rcs" | "whatsapp"` — this mapping applies **only** to that channel.
- `channel_type: null` — applies to **any** channel that doesn't have a more specific mapping for that
  variable name (a shared/global fallback).

**Your variable-mapping form must be built per-channel**, not as one flat list — for each enabled channel
on the campaign, show that channel's template's variables and let the user map each one independently, even
if the variable name collides with another channel's. For `sms`/`whatsapp` these are the literal `{{n}}`
placeholders (`"1"`, `"2"`...); **for `rcs` they're the *named* variables** (`"Discount"`, not `"1"`) — see
the note in §1.3. Always source the list from `/preview`'s `variables_used` per channel rather than
re-deriving it client-side, so this distinction doesn't need to be reimplemented in the frontend.

### 5.2 Resolution order (what the backend actually does, in case you need to explain it to a user or
debug why a variable isn't filling)

For each variable in a channel's template (the literal `{{n}}` token for `sms`/`whatsapp`, the *named*
variable for `rcs` — e.g. `"Discount"`, per §1.3), the value is resolved in this order:
1. `recipient_data["{channel}.{variable}"]` — e.g. `recipient_data["whatsapp.1"]` or `recipient_data["rcs.Discount"]`
   (an advanced/manual escape hatch; not needed if you use the mapping endpoint correctly)
2. `recipient_data[variable]` — a plain, unscoped key (only safe if no other channel uses the same
   variable name)
3. A `CampaignVariableMapping` row scoped to this channel (`channel_type` matches)
4. A `CampaignVariableMapping` row with `channel_type: null` (global fallback)
5. That mapping's `default_value`
6. If nothing resolves, the literal `{{variable}}` text is left in the message (for WhatsApp) or the
   variable is simply omitted from the provider's `var`/`params` payload (for RCS/SMS) — **always test with
   Preview (§6) before launching**.

### 5.3 Fields

| Field | Required | Notes |
|---|---|---|
| `variable` | one of `column`/`default_value` | the placeholder name as it appears in the template, without braces (e.g. `"1"`) |
| `column` | one of `column`/`default_value` | which `recipient_data`/Excel column to pull the value from. Omit for a test audience (§2.3) or any source with no real columns — provide `default_value` instead, since a test/manually-typed number has no `recipient_data` beyond `phone` |
| `channel_type` | no | `sms`/`rcs`/`whatsapp`/omit — see §5.1 |
| `default_value` | required if `column` omitted | used if the column is missing/empty for a recipient, or as the value outright when no `column` is mapped |
| `is_required` | no | if true and unresolved, `preview` (§6) will surface a validation error |

Calling this endpoint **replaces all existing mappings** for the campaign (not additive) — always submit
the full mapping set.

---

## 6. Preview — always call this before launching

`POST /api/campaign/preview` — permission `.read`/`.override`. Body: `campaign_id`, optional `sample_data`
(defaults to the first recipient's data, or a row of the Excel preview if no recipients exist yet).

Returns, per enabled channel: `template_name`, `template_id`, `raw_body` (extracted plain-text body),
`rendered_body` (with variables substituted per §5.2), `variables_used`, `sample_data`. **Use this screen to
catch missing sender IDs, unresolved variables, or wrong template content before spending a send.**

---

## 7. Fetching campaigns / details

`POST /api/campaign/fetch` — permission `.read`/`.override`.
- With `id`: single campaign, fully loaded (`channels`, `audience`, `variable_mappings`, `recipients_count`).
- Without `id`: paginated list. Filters: `status`, `strategy`, `search` (name substring), `limit`.

Both branches eager-load `audience.leadGroup`, so `audience.lead_group_name` (alongside `lead_group_id`) is
always present for lead-sourced audiences — the frontend doesn't need a separate lookup to show the group's
name in campaign lists/detail.

---

## 8. Launching

`POST /api/campaign/launch` — permission `marketing_management.campaign.launch` (or `.override`). Body:
`campaign_id` only.

- Fails (400) if the campaign has no recipients, or its status isn't `draft`/`scheduled`/`paused`.
- Before dispatching anything, `CampaignService::launchCampaign()` registers a Journey for the campaign
  (`CampaignExecutionService::createJourneyForCampaign()`, `POST {journey}/journey/create`) — one component
  per enabled channel, ordered by `priority`, each with the channel's `sender_id`/`templateName`/
  `fallback_time`. The returned/derived journey name is stored on `campaigns.journey_name`; this only happens
  once per campaign (idempotent — a re-launch after pause reuses the existing journey).
- On success: status → `scheduled`, and a background job (`ExecuteCampaignJob`) is queued to actually send.
  Campaigns over 50,000 recipients are split into multiple staggered chunk jobs instead. Per recipient, the
  job calls `CampaignExecutionService::executeJourneyMessage()`, which sends **one** `channel: "Journey"`
  `/api/v1/send` call (not one call per enabled channel) — the provider tries the campaign's channels in
  priority order internally, falling back per each channel's `fallback_time`, instead of this app looping
  channels and issuing separate SMS/RCS/WhatsApp requests itself.

**This requires Laravel's scheduler to actually be running** — `routes/console.php` defines two
`everyMinute()` schedule entries, `laravel-queue-worker` (`queue:work --queue=default --stop-when-empty
--tries=3`) and `campaign-queue-worker` (same, `--queue=campaigns`), which drain whatever's pending each
minute rather than running as a persistent worker process. Those only fire if something is invoking
`php artisan schedule:run` every minute (a system cron entry / Windows Task Scheduler job in production; on
a local Windows/Laragon box with no such cron entry, nothing drains the `jobs` table and a launched campaign
sits at `scheduled` forever until `schedule:run` is run manually). This is an infrastructure/deployment
requirement, not something the frontend can control, but the frontend should be aware a "launched" response
does not mean "sent" — poll `fetch_stats` (§10) to observe actual progress.

Campaign statuses you'll see over its lifecycle: `draft → scheduled → running → completed` (or `failed` if
the job errors unrecoverably).

---

## 9. Pause / Resume / Stop — **currently disabled, do not build UI for these yet**

`POST /api/campaign/pause`, `/resume`, `/stop` all exist as routes and pass their permission checks, but
**immediately return `400` with a "temporarily unavailable" message** and do nothing else. This is
intentional: the execution job processes all pending recipients in one uninterruptible run and never
re-checks the campaign's live status mid-flight, so pausing/stopping a campaign that's actively sending
would previously appear to work in the database while messages kept going out underneath it — a
correctness gap, not a stub.

**Do not wire buttons for these to the UI right now.** If/when the underlying job is fixed to check status
between chunks, this section will be updated and the endpoints will start working with no request/response
shape changes.

---

## 10. Monitoring — logs and stats

`POST /api/campaign/fetch_logs` — permission `.read`/`.override`. Body: `campaign_id`, optional `status`
(`pending`/`sent`/`delivered`/`failed`), `channel_type`. Returns paginated `CampaignLogResource[]` — one row
per recipient Journey send attempt (`channel_type: "journey"`, `campaign_channel_id: null`, since one
call now covers every enabled channel for that recipient — older campaigns sent before this change may still
have per-channel `sms`/`rcs`/`whatsapp` log rows), with `message_id`, `status`, `error_message`, timestamps.

`POST /api/campaign/fetch_stats` — permission `.read`/`.override`. Body: `campaign_id`. Returns:

```json
{
  "total_recipients": 100,
  "sent_count": 92,
  "failed_count": 8,
  "delivered_count": 0,
  "pending_count": 0,
  "success_rate": 92.0,
  "delivery_rate": 0.0,
  "channel_stats": {
    "journey": { "sent": 92, "delivered": 0, "failed": 8 }
  }
}
```

`channel_stats` is now keyed by `campaign_logs.channel_type` — for campaigns launched since the Journey API
switch (§8) that's always a single `"journey"` key (one send call already covers every enabled channel per
recipient, so there's no longer a per-channel breakdown from this app's own logs). Campaigns logged before
the switch may still show `sms`/`rcs`/`whatsapp` keys from their existing log rows.

**Note on `delivered_count`/`delivery_rate`:** this system only tracks whether the provider **accepted**
the send (`sent`/`failed`), not final carrier/handset delivery. There is no webhook endpoint wired up to
receive delivery-report callbacks from Bluewaves Media/Meta, so `delivered_count` will read `0` in
practice — actual delivery status (delivered, read, rejected by carrier, DND-blocked, quota-limited, etc.)
currently has to be checked on the provider's own dashboard, not through this API. Flag this to users if
you're building a "delivered" indicator — it won't reflect real delivery today.

**Confirmed: a `"sent"` `campaign_logs` row means "Bluewaves accepted the enqueue request," not "the
journey/template/agent referenced actually exist."** A live `POST /send` call with a deliberately
fake, never-created `journeyname` (`"Sample_Journey"`) and fake `templateName`/`agentID` values still
returned `200` with `{"message": "Journey request enqueued successfully.", "journeyCampaignMaskId":
"JRNY-..."}` — the same success shape as a real campaign send. So `/send`'s synchronous response validates
essentially nothing beyond request shape; whatever validation happens against the actual journey/template/
agent registry happens **asynchronously**, after the 200, with no callback wired up here to observe the
outcome. Combined with no delivery webhook (above), this means: **this app currently has no way to detect a
send that was accepted but silently failed downstream** (bad journey name, deleted template, wrong agent
ID, etc.) — only the provider's own portal/dashboard can show that. Don't treat `status: "sent"` as proof
the message will actually reach the recipient or appear correctly in Bluewaves' reporting.

`POST /api/campaign/check_balance` — permission `.read`/`.override`. No body. Passes through the provider's
`GET {journey base_url}/user/balance` response as `data`, authenticated with the same `campaign_api_credentials`
Bearer token used for `journey/create`/`/send` (`JourneyApiClient::checkBalance()`). Returns 502 (not the
provider's own status code) if the credential is missing or the call fails — check `message` for the reason.
The response shape is whatever Bluewaves' `/user/balance` returns; it isn't documented in the provider's
published API reference, so treat field names as unverified until confirmed against a live account.

---

## 11. Deleting a campaign

`POST /api/campaign/delete` — permission `.delete`/`.override`. Body: `id`. Blocked (400) while the
campaign is `running`.

---

## 12. Known real-world failure modes worth designing error states for

These aren't bugs — they're provider/telecom-level outcomes your UI should be able to explain to a user
looking at `fetch_logs`:

| Symptom | Cause |
|---|---|
| `error_message: "Campaign API key not configured"` | `campaign_api_credentials.api_key` (§0) not seeded — every Journey create/send call fails until it is |
| `error_message: "Failed to create journey: ..."` (campaign never leaves `scheduled`) | `journey/create` rejected the request — check `sender_id`/`templateName`/DLT fields per channel (§2.2), same underlying requirements as before, just surfaced at launch time instead of per-send |
| `error_message: "...Journey name can only contain letters, digits, spaces, underscores and hyphens..."` | Should no longer occur — `createJourneyForCampaign()` (§12.1) strips disallowed characters from the campaign name before sending it as the journey `name`. If seen, an edge case in that sanitization slipped through |
| WhatsApp component/node missing `wabaBusinessId` | `waba_credentials.waba_business_id` (§0) not set |
| WhatsApp error code `131053` | Header media link isn't a real, durable public URL — see §3.4. Confirmed live: a Journey WhatsApp channel with **no** `header_*_link` set in `credentials_config` produced this exact error, because nothing told Bluewaves what media to use for the template's `IMAGE` header. `buildWhatsAppHeaderData()` now reuses the same `credentials_config` keys as before (§3.4) to populate the Journey node's `headerFormat`/`headerVariables` — this is required whenever the selected template has a media header, not optional |
| WhatsApp error code `131026` | Recipient's number has no active WhatsApp account — nothing to fix |
| RCS error code `429`, "aggregate daily promotional message quota has been exceeded" | Recipient-side rate limit from Google — retry later, not an error to surface as a failure |
| RCS status `NON_RCS_NO` | Recipient's device/carrier doesn't support RCS at all |
| `campaign_logs.status: "sent"` but `message_id: null`, and/or message never appears in the Bluewaves portal's own reporting | Two now-fixed causes, confirmed against a live account: (1) `executeJourneyMessage()`/`logJourneyAttempt()` used to discard the `/send` response entirely, so `message_id`/`response_data` were always `null` regardless of outcome — fixed to persist both on every attempt (§8). (2) a successful `/send` call's body is `{"code": "200", "message": "<json string>"}`, where the *inner* JSON string (not `data.messageId`/`messageId` as first assumed) carries the real id under `journeyCampaignMaskId`, e.g. `"JRNY-AZ_hD8FmmfZNLMyft_E"` — `JourneyApiClient::extractJourneyCampaignMaskId()` now parses this correctly. Also note the confirmed response says `"Journey request enqueued successfully"` — enqueued, not delivered/sent — so a brief delay before it's visible in Bluewaves' portal is expected; if it's still missing after that, look the send up in their portal by `journeyCampaignMaskId`, not by campaign/journey name |
| SMS delivery status "rejected", code `650` (or similar carrier codes) | Carrier-side DLT/DND rejection, happens *after* our system already reported `sent` — this is why `sent` ≠ `delivered` (see §10) |

### 12.1 Journey API — confirmed vs. still-unverified behavior

The Journey `journey/create` endpoint and the exact `journeyname`/`journeypayload` interplay at send time
aren't in Bluewaves' published API reference (only the direct per-channel `/api/v1/send` shapes are).

**Confirmed against a live Bluewaves account** (via its own validation error messages, calling
`journey/create` for real):

- `mode` must be `"Sequential"`, `"Fallback"`, or `"Parallel"` (title-case) — anything else is rejected with
  `"Invalid SendingStyle"`. `campaigns.strategy` maps `failover` → `"Fallback"`, `parallel` → `"Parallel"`
  (`CampaignExecutionService::MODE_MAP`). There's no campaign-side concept matching `"Sequential"` yet.
- `numberFormat` must be `"National"` or `"International"` — `"E164"` is rejected with `"Invalid
  PhoneNumberFormat"`. **`"International"` journeys only allow `SMS`/`WhatsApp` components — RCS is
  rejected** with `"International journeys allow only SMS and WhatsApp"`. Defaulted to `"National"`
  (`services.campaign_journey.number_format`) since this account sends to Indian numbers and needs RCS
  available; switch to `"International"` only for campaigns that exclude RCS.
- Each `journey/create` component's `templateName` must be the provider's **registered template name**
  (e.g. `"SUTRAA"`), not the DLT numeric ID used by the raw non-Journey SMS send API — using the DLT ID
  produces `"Template '<id>' not found for type 'SMS'"`. `creationTimeTemplateName()` now sources `name`
  first for every channel type.
- Each `journey/create` component's `fallbackTime` must be **minutes**, and one of `1, 15, 30, 60, 120, 240`
  exactly — any other value is rejected with `"Fallback time must be one of: 1, 15, 30, 60, 120, 240
  minutes"`. `campaign_channels.fallback_time` is validated to this enum at create/update time (§2.2);
  `services.campaign_journey.default_fallback_time` defaults to `60`.
- The **last** component (highest `priority`) must **not** have a `fallbackTime` at all — sending one is
  rejected with `"Last node must not have a fallback time"` (there's nothing left to fall back to).
  `createJourneyForCampaign()` omits the field entirely for the last channel in priority order, regardless
  of what's set on that `campaign_channels` row's `fallback_time`.
- A real account can still reject journey creation for reasons outside this app's control — e.g. `"No
  active default SMS route found for your account"` is an account-provisioning gap on Bluewaves' side
  (their portal/support), not a payload bug.
- A successful `/send` call returns `{"code": "200", "message": "<json string>"}` — the inner JSON string
  decodes to `{"message": "Journey request enqueued successfully.", "journeyCampaignMaskId": "JRNY-..."}`.
  There is no `data.messageId`/`messageId` at the top level as the original (unverified) implementation
  assumed; `journeyCampaignMaskId` is the real per-send id and is what `campaign_logs.message_id` now stores
  (§12, "message never appears in the portal").
- `name` may only contain letters, digits, spaces, underscores and hyphens — anything else (e.g. `#`, other
  punctuation) is rejected with `"Journey name can only contain letters, digits, spaces, underscores and
  hyphens."`. `createJourneyForCampaign()` builds the name from `campaign->name`, so it strips disallowed
  characters from the campaign name and appends `" - {$campaign->id}"` (not `" #{$campaign->id}"`) for
  uniqueness across campaigns with the same display name.
- RCS journey node `bodyData` is keyed by the template's **positional** `{{N}}` placeholder (e.g.
  `{"{{1}}": "20%"}`), **not** the named variable (e.g. `{"Discount": "20%"}`) — confirmed against the
  provider's official Single Sending API PDF (§"Journey" → RCS Node table: `bodyData: Map of {{1}} → value
  substitutions`), which contradicts what this app originally assumed (named keys, by analogy with the
  standalone non-Journey RCS channel's `content.params`, which *is* named-key). Fixed:
  `TemplateVariableService::extractRcsVariablePositions()` now preserves the `{{N}}` placeholder each named
  variable maps to (from the same `TitleVariables`/`DescVariables` card structure `extractRcsVariables()`
  already walks), and `buildRcsNodeData()` keys `bodyData` by that placeholder instead of the name.
  `extractRcsVariables()`'s named list is unchanged and still what `resolveVariableValue()` looks up
  recipient data/mappings by — only the final payload key changed.
- **WhatsApp journey node `bodyVariables` must have exactly one entry per `{{N}}` the approved Meta template
  declares, in template order — regardless of how many of those are actually mapped.** `buildNodeData()`
  used to hand `buildWhatsAppNodeData()` the *rendered* (post-substitution) body and re-extract variables
  from that: any `{{N}}` that already had a mapping had already been replaced with its value by
  `renderTemplate()`, so `extractVariables()` on the rendered text only ever found the *unmapped* leftovers —
  and reindexed them by array position, not their real `{{N}}`. A 5-variable template with only variables
  1–4 mapped (campaign 15's `otp5`, seen live) produced a `bodyVariables` array with a single entry
  mislabeled `index: 1, format: "{{1}}"` for what was actually `{{5}}` — Meta's Cloud API rejects a
  component/parameter count that doesn't match the template's declared count, so **every WhatsApp leg of
  every journey send using a partially-mapped template was silently failing downstream** (the `/send` call
  itself still returned `200`/enqueued per the note above, giving no indication anything was wrong). Fixed:
  `buildNodeData()` now passes the *original* `templateBody` to `buildWhatsAppNodeData()`, so all `{{N}}`
  variables are extracted in their real order regardless of mapping completeness; an unmapped one still gets
  `resolveVariableValue() ?? ''` (empty string) but at the *correct* index/format. **This does not fix an
  incomplete mapping itself** — campaign 15's `otp5` template still needs a mapping for variable `5` (and
  its WhatsApp channel has no `sender_id`/`credentials_config.channel_name`, so `channelName` sends as `""`
  — also worth setting) for the message content/routing to actually be complete; check both when a WhatsApp
  send in a journey doesn't show up correctly.

**Still unverified** — re-check before depending on these for a production send:

- `product` casing in `journey/create` components (`SMS`, `RCSMessage`, `WhatsApp`) is mirrored from the `/api/v1/send` channel enum, which uses different casing from the `journeypayload.nodes[N].templateType` enum (`SMS`, `RCSMESSAGE`, `WHATSAPP`) used at send time.
- Every send includes both `journeyname` (the created journey, for correlation) and a full `journeypayload` (per-recipient personalized values) — unconfirmed whether `journeyname` alone would suffice once a journey is registered.
- Each send targets exactly one recipient (`to: [recipient.full_phone]`); batching multiple recipients into one `to` array with varying `bodyVariables` per recipient was not attempted since the docs don't clarify that.
- WhatsApp `templateName` at creation time now sources `template.name` (e.g. `"otp5"`) rather than the numeric Meta template ID, by analogy with the SMS fix above — this specific case hasn't produced its own confirming error yet.
- The provider's own "Single Sending API" PDF (v1.0, April 2026) documents `POST /api/v1/send` (all channels including Journey) but **not** `journey/create` — that endpoint is still entirely unverified against any published reference, only against this account's live validation error messages (§12.1 above). The PDF's documented success envelope for `/api/v1/send` is also `{"success": true, "statusCode": "200", "data": {"messageId": ..., ...}, "traceId": ...}` — this does **not** match the actual Journey `/send` response captured from this account (`{"code": "200", "message": "<json string containing journeyCampaignMaskId>"}` — see §12, "message never appears in the portal"), so the documented envelope may only apply to non-Journey channels, or Journey responses may differ from the general spec in practice. Trust the live-captured shape over the PDF here.

### 3.4 WhatsApp media headers — the one gotcha every template with an image/video/document header hits

A template's `raw_body` HEADER component includes an `example.header_handle` URL — **this is a
preview-only asset for Meta's template-review UI and cannot be reused for a real send.** Attempting to
reuse it produces error `131053` ("Media upload error") because WhatsApp's servers can no longer fetch it
(or it was never durable to begin with).

**You must supply your own publicly-reachable HTTPS URL** for the header media via the channel's
`credentials_config` at campaign-creation time (§2.2):
- `header_image_link` for `IMAGE` headers
- `header_video_link` for `VIDEO` headers
- `header_document_link` (+ optional `header_document_filename`) for `DOCUMENT` headers

Your campaign-creation UI should detect the header format from the template (§1.3) and, if it's a media
type, require the user to supply (or select from your own asset library) a real hosted URL before allowing
campaign creation to proceed.

---

## 13. Suggested frontend build order

1. Settings screens for the three provider credentials (§0) — one-time, admin-only.
2. Template browser: list cached templates (§1.1) + "import from provider" form (§1.2) per channel, with a
   preview renderer that understands each channel's `raw_body` shape (§1.3) and flags media headers needing
   a URL (§3.4).
3. **Lead Groups screen, in the Leads module** (§1.5): filter leads → let the user refine the result set via
   multi-select → save as a named, reusable group. Build this before the campaign wizard's audience step,
   since that step just picks an existing group by ID.
4. Campaign creation wizard:
   a. Name + strategy (parallel/failover)
   b. Add channels — pick template, set priority, and **prompt for the per-channel `sender_id`/
      `credentials_config` fields from the table in §2.2** (this is the step most likely to be
      under-built — it's easy to forget these aren't part of the template itself)
   c. Audience — its own step **after** the campaign is created: pick an existing Lead Group
      (`select_lead_group`) or do an Excel upload (§2.3, §4)
   d. Variable mapping — **built per-channel** (§5.1), not as one shared list
5. Preview screen (§6) — mandatory gate before allowing "Launch"
6. Launch button + status/progress view (§8, §10) — poll `fetch_stats`/`fetch_logs`, and be explicit in the
   UI that "sent" means "accepted by provider," not "delivered" (§10, §12)
7. Do **not** build Pause/Resume/Stop controls yet (§9)
