# Events · Funnel WhatsApp automation (Manage)

**Portal:** Manage · **Home:** the funnel hub's **Automation** tab (`/manage/events/funnels/{uuid}` → *Automation* — the tab was renamed from *WhatsApp* when email/SMS/Zoom-email channels joined it; WhatsApp is now one channel among several, but everything below — routes, tables, scanner, modal — is unchanged. The tab shell itself is documented in [Funnel Automation](/docs/modules_handbook/manage/events/funnel-automation/readMe.md)) · **Routes:** `manage.events.funnel-whatsapp.*` · **Command:** `whatsapp:run-funnel-reminders`

## What it does
Funnel WhatsApp automation, configured by an admin on the funnel hub. Two rule kinds, at **two different scopes**:

1. **Welcome (on registration)** — **funnel-level OR slot-level** (`event_series_id` null = the whole-funnel welcome; set = that slot's own welcome). The moment a lead registers (landing form → `RegisterLeadAction` → `DispatchFunnelWelcomeAction`), **exactly ONE welcome scope fires**: a **slot-landing** registration (one session) sends **that slot's** welcome — overriding the funnel one — falling back to the **funnel-level** welcome when the slot has none; a **funnel-landing** registration (enrols **all** upcoming sessions) sends the **funnel-level** welcome alone (slot welcomes are suppressed, or a multi-slot funnel would fire one message per slot). Fires on **every** registration — a returning person re-registering gets it again.
1a. **After they register (VIDEO SALES LETTER funnels)** — a set time after `lead_funnels.registered_at`, narrowed by **how far into the video they got** and **whether they have a 1-on-1**. A VSL has no sessions, so this is the only timed trigger that works on one — full detail in *After they REGISTER* below.

2. **Reminders (before a session)** — **slot-level** (`event_series_id` **required**). Each slot runs a different class, so each slot carries its **own** reminder rules: a set lead time before each of **that slot's sessions the lead is registered to** (`Event.scheduled_date + start_time`), auto-send a reminder. Multiple offsets are allowed per slot (e.g. *24 hours before* **and** *1 hour before*). There is **no "all slots" reminder** — a reminder always names its slot; a legacy slot-less reminder row **never fires** (the scanner skips it) and the tab flags it **"Assign a slot"** until an admin edits it and picks one.

Content is **provider-aware**: an **official (Cloud) number requires an approved Meta template** (single message); a **QR (Bridge) number sends free-form** — and a Bridge rule is now a **multi-message step builder**: up to `MAX_STEPS` (5) ordered **text bubbles and/or media messages** (image / video / document with an optional caption) sent together in one shot, human-paced (≥2s cumulative gap between bubbles so delivery order holds). Multi-message stays Bridge-only because Meta only allows **templates** outside the Cloud 24h window — a web registration never opens one. Both providers support these `{{token}}`s, rendered per recipient:

| Token | Value |
|---|---|
| `{{name}}` | the lead's full name (`{{first_name}}` = first word — still substituted on legacy rules but **no longer offered in the picker**, like `{{funnel}}`) |
| `{{event_title}}` | the session title (falls back to the slot's short label / "the session") |
| `{{event_date}}` | the session date, formatted **`13 June 2025`** (`j F Y`) |
| `{{event_time}}` | the session time, a **12-hour range** — **`8:00pm - 10:00pm`** (`start_time`–`end_time`; just the start when there is no end) |
| `{{event_location}}` | a **Zoom** session → the lead's **short join link** `/zoom/{token}` (`session.join`), which redirects to their personal registrant `zoom_join_url`, else the webinar's shared `join_url`, else the session's static `zoom_link`. Zoom's own registrant URL is **209–240 characters** of opaque token — unreadable in a WhatsApp bubble and unverifiable by the recipient — so the short link is sent instead. It resolves its destination **at click time**, so a reminder delivered before the Zoom push landed upgrades itself to the personal link with no new message, and a click is attributable to exactly the lead who registered (tallied on the row + written to their activity trail). A send with no registration at all (a test send) falls back to the shared links directly. A **physical** session → the venue (`events.location`) |
| `{{register_link}}` | a **Zoom** session → the webinar's own **registration page** URL (`registration_url`) — for a lead to sign THEMSELVES up on Zoom's page (distinct from `{{event_location}}`, which is their personal join link once registered); empty for a physical session or before the webinar exists |
| `{{funnel}}` | the funnel name (legacy — substituted but no longer offered in the picker) |
| `{{whatsapp_group_link}}` | the funnel's **WhatsApp group invite link** — a per-funnel field (`event_funnels.whatsapp_group_link`), set on the funnel edit modal (e.g. `https://chat.whatsapp.com/…`) |
| `{{video_link}}` | **VSL funnels only** — the funnel's video page, `/{slug}/video`. Empty on a webinar funnel |
| `{{watched_minutes}}` | **VSL only** — whole minutes of the video they have watched (`0` when they never played) |
| `{{completion_percent}}` | **VSL only** — how far through they got, e.g. `46%`. **Empty, never `0%`, when unmeasurable**: the player not reporting a duration is our gap, not their behaviour, and telling somebody they watched 0% of a video they finished is worse than saying nothing |
| `{{login_link}}` | a **secure magic sign-in link** to the lead's portal account — a signed `auth.magic` URL (7-day expiry, minted per send); empty when the lead has no linked account. On a per-lead send it also carries `ch=wa` + a **digest of the number this message is going to**, so clicking it *proves the phone* and signs the lead in on that one key ([login handbook](/docs/modules_handbook/shared/user-lead-admin-login-register-merge/readMe.md)). Changing the phone retires every link minted before the change. |

> ⚠️ **A WELCOME rule must contain `{{login_link}}`** — `StoreRequest` refuses to save one without it. The whole phone-first onboarding hangs on that link: it is how a fresh lead signs in at all *and* how their number gets proven. (Bridge free-form only — a Cloud template's body lives at Meta and cannot be linted here.)
>
> ⚠️ **`{{login_link}}` is always a real link, but not always a credential.** Each send carries `allow_auto_login`, which becomes the signed `s=1` param on the URL. It is true only for a registration where **no email-holding account existed beforehand** — otherwise anyone who knows a customer's email could have that customer's sign-in credential delivered to their own phone.
>
> | | tapping it does |
> |---|---|
> | with `s=1` | proves the phone, signs in, lands on `/dashboard` |
> | without | proves the phone, then asks for the account's **email** at `/verify-email` before any session |
>
> So the message copy "tap here to sign in" is true either way, and nobody is ever left at a dead end. A **welcome backfill** never carries `s=1` (it cannot know who wrote the phone on file).
>
> The send also carries `deliver_to`: a per-send delivery number that overrides the account's stored phone, used when the typed number could not be stored because another account owns it — the person still receives their bonus.
| `{{ticket_link}}` | a **signed public QR ticket page** (`main.ticket.show`, `/t/{registration uuid}`) for the lead's registration on this session — **physical sessions only** (a Zoom session renders empty, like the other absent event tokens). Tamper-proof + **no expiry** (a ticket must outlive its send time); the page leaks no PII beyond the first name. Empty when the lead has no registration on the session. A test send shows a signed sample |

The lead + funnel tokens (`name` / `first_name` / `funnel` / `whatsapp_group_link` / `login_link`) are available on **every** send. The **event** tokens (`event_title` / `event_date` / `event_time` / `event_location` / `ticket_link`) use the reminder's session — and on a **welcome** they resolve to the lead's **next registered session in this funnel that has not started yet** (the instant is checked in PHP — `Event::upcoming()` compares dates only, so on its own it would still describe a session that finished earlier the same day) (narrowed to the rule's **slot** for a slot welcome, so they describe the session they just joined) (registration auto-enrols the lead into upcoming sessions via `EnrollLeadInFunnelSessionsAction` right before the welcome fires), so a welcome can carry the session details too. No session at all → they render empty.

This is **not a Flow** — the trigger model (an app event + a per-session lead time) and the funnel scope don't fit a channel-scoped keyword/Sheet flow. Instead it is a thin trigger/schedule layer that **reuses the WhatsApp send engine** (drivers, the pacing lane, opt-out skip, the message pipeline). See [flow.md](/docs/modules_handbook/manage/messages/whatsapp/flow.md) for why they're kept separate (a welcome could still *start a flow* in a future iteration).

## How it works

### Data model (3 tables)
- **`funnel_whatsapp_messages`** ([FunnelWhatsappMessage](/src/Event/FunnelWhatsappMessage.php), key model: uuid + blame + soft delete) — one rule: `event_funnel_id`, **`event_series_id`** (the rule's **slot** scope — **required for reminders**; on a welcome it is **optional**: NULL = the whole-funnel welcome, set = that slot's own welcome; `series()` relation), `channel_id` (the number that sends), `trigger` (`TRIGGER_ON_REGISTER` / `TRIGGER_BEFORE_EVENT`), `offset_minutes` (reminders), provider-aware content (`template_name` / `template_language` / `template_params` json for Cloud; `steps()` — or the legacy single `body` — for Bridge), `is_active`.
- **`funnel_whatsapp_message_steps`** ([FunnelWhatsappMessageStep](/src/Event/FunnelWhatsappMessageStep.php), child, no uuid — mirrors `whatsapp_flow_steps`) — a Bridge rule's **ordered message parts**: `position`, `type` (`TYPE_TEXT` / `TYPE_MEDIA`), `body` (text, or the media caption; `{{tokens}}` supported), `media_id` (→ the shared `Media` on GCS, collection `funnel_whatsapp`, attached to the **EventFunnel** since a step's media is uploaded before the rule row exists), `delay_seconds`. **Replaced as a group on save** (`replaceSteps`). A rule with steps ignores `body`; a legacy rule without steps falls back to it.
- **`funnel_whatsapp_sends`** ([FunnelWhatsappSend](/src/Event/FunnelWhatsappSend.php), append-only child ledger, no uuid/blame) — one row per delivery: `funnel_whatsapp_message_id`, `lead_id`, `event_id` (null for a welcome), `whatsapp_message_id`, `status` (Queued/Sent/Skipped/Failed) + `skip_reason`, `sent_at`. **`UNIQUE(message, lead, event)`** — the de-dupe: MySQL treats **NULLs as distinct** in a unique index, so a **welcome** (event_id null) may fire on every registration, while a **reminder** (event_id set) fires at most once per (rule, lead, session).
  - **`SKIP_*` reasons** (`skip_reason`, with human wording in `SKIP_REASONS`): `no_phone` · `opted_out` · `missing_config` · `empty_content` — all terminal — and **`template_pending`**, which is *resumable*: the rule was fine but its Cloud template wasn't approved yet, so the scanner **backfills** the row once Meta approves (see below).
  - **`outcome()` + `OUTCOMES`** — the single value the admin UI reads, folding the ledger status with the **outbound message's delivery state**: `queued` · `sent` · **`delivered`** · **`read`** (blue ticks) · `failed` · `skipped`. Everything in the analytics surfaces below is counted on this.

### Welcome — inline on registration
[`RegisterLeadAction`](/app/Actions/RegisterLeadAction.php) (after it records the funnel registration + WhatsApp opt-in) calls, best-effort, [`DispatchFunnelWelcomeAction`](/app/Actions/DispatchFunnelWelcomeAction.php), passing the **slot** the person registered through (a slot landing) or null (a funnel landing). The action picks **one scope** among the funnel's active `TRIGGER_ON_REGISTER` rules — the slot's own welcome for a slot registration (falling back to the funnel-level welcome when the slot has none), the funnel-level welcome for a funnel-wide registration — then creates a QUEUED ledger row per chosen rule → dispatches `SendFunnelWhatsAppMessage` on the **default** lane (a welcome is a one-off, kept near-instant). A WhatsApp hiccup never fails the sign-up (guarded like the rest of the action).

### Reminders — a scheduled scan
[`whatsapp:run-funnel-reminders`](/app/Console/Commands/RunFunnelReminders.php) (scheduled **every five minutes**, `withoutOverlapping`): for each active `TRIGGER_BEFORE_EVENT` rule — **skipping any rule with no `event_series_id`** (a legacy slot-less reminder fires for nothing until assigned) — load **the rule's slot's** upcoming sessions (`Event::upcoming()` filtered by `event_funnel_id` **and `event_series_id`**), narrowed by date, then keep those whose **lead-time point just passed** — `start − offset ≤ now < start` **and** `start − offset ≥ now − CATCH_UP_MINUTES` (**60**); the start is `scheduled_date + start_time` in PHP, as `start_time` is a wall-clock string. For each due session, every **registered** lead (`EventRegistration` status `REGISTERED`) **that has no ledger row yet** (a `whereNotExists` against `funnel_whatsapp_sends`, so a long lead time like 24h doesn't re-scan thousands of registrants on every tick — once all are claimed the scan is empty) is **claimed** (`claimReminderSend` → the unique index makes it fire once) and a `SendFunnelWhatsAppMessage` is queued on the capped **`redis-broadcast`** lane so a big session never bursts. **Why the catch-up bound matters.** Without it the rule is due for the *whole* stretch between its lead time and the session, so anything that first becomes eligible **inside** that stretch fires immediately with a stale claim: a "24 hours before" rule authored at 2pm for an 8pm session — or a lead who **registers** that afternoon on an already-existing rule — would be told "24 hours to go" six hours before the start. Bounding it to the last hour keeps the message honest while still absorbing a scanner outage: a tick missed by up to an hour is caught up, anything later is **skipped for that session only** (the rule's later sessions are untouched, and a **shorter** rule on the same session still fires at its own point). The narrow band + the unique claim mean the reminder fires **once**, right at the offset point (a recovered scanner sends it slightly late, never twice, never after the session). The lead time is **floored at 5 minutes** (`StoreRequest` `min:5`): the scanner runs every 5 minutes, so a finer offset could have a due-window narrower than the scan cadence and slip between ticks — a ≥5-minute window always contains a tick, so the reminder is guaranteed.

### The send job (provider-aware, reuses everything)
[`SendFunnelWhatsAppMessage`](/app/Jobs/Whatsapp/SendFunnelWhatsAppMessage.php) processes one claimed ledger row: resolve the lead's WhatsApp contact from its phone (`findOrCreateByPhone`); **skip** an opted-out (`isOptedOut(CATEGORY_MARKETING)`) or blocked contact; build the message — a **Cloud** channel → `TYPE_TEMPLATE` (`meta.template` name + language + substituted **positional** params → `CloudApiDriver::sendTemplate`; params keep their `{{1}}`, `{{2}}` … index — a value that substitutes to empty becomes a neutral filler, never dropped, or every later param would shift into the wrong slot and Meta would reject the count), a **Bridge / Sandbox** channel → the rule's **steps** (each step its own outbound: a `TYPE_TEXT` bubble, or a media message typed by mime — image / video / audio / document — with the caption as `body` + a `whatsapp_attachment` linking the stored `Media`; each `SendWhatsAppMessage` dispatched with a **cumulative ≥2s delay** so the bubbles arrive in authored order; steps carry a `meta.funnel_whatsapp {rule, send, step}` audit trail) or the legacy single `TYPE_TEXT` body — with `{{token}}` placeholders substituted; then `conversationFor` + `createOutbound` + `SendWhatsAppMessage` (the normal outbound pipeline — echo, ticks, delivery). The ledger row is marked Sent (+ the **first** message id) / Skipped (+ reason) / Failed. Idempotent — it only acts on a still-QUEUED row (a crash mid-step-loop marks the row FAILED; at-least-once semantics like the flow drip).

### Provider rules (Cloud vs Bridge)
- **Cloud (official):** a **template** is required (`StoreRequest::withValidator` enforces one once the channel's provider is known; templates come from `whatsapp:sync-templates` — or are **authored inline** from the rule modal, see below). A template is sendable anytime — no 24h-window issue. A rule **may reference a still-PENDING template**: it saves fine but is **held**, and **starts sending automatically once Meta approves** (`FunnelWhatsappMessage::hasApprovedTemplate()` is the gate — keyed on **(channel, name, language)**, matching both the `whatsapp_templates` unique index and the `language.code` the send emits, so a pending `ms_MY` variant is not released by an approved `en_US` one; the tab badges such rules *"Template Pending"*). The two triggers hold it differently, because one is resumable in place and the other is a moment in time:
  - **Reminder** — `RunFunnelReminders` skips the rule **without claiming**, so a scan while the template is approved picks the still-due session up normally. It is deliberately **not** recorded as a skipped row: claiming would burn the de-dupe slot, and the sweep never resumes a reminder, so an approval landing seconds later — still inside the catch-up band — would find the session already claimed and never send it. The cost is that a template approved **after** the band passes leaves no per-session trace; that reminder is genuinely gone for that session (sending "24 hours to go" late is precisely what the band prevents), and the tab badges the rule *"Template Pending"* so the cause is visible.
  - **Welcome** — a moment-in-time trigger, so the ledger row **is** written and the send job records it **`Skipped(template_pending)`** (never dropped silently). `RunFunnelReminders::backfillApprovedTemplates()` then **re-queues** every such row — **welcomes only (`event_id` IS NULL)**, bounded to the last **14 days**, active rules only, via `FunnelWhatsappMessageRepository::requeue()` — as soon as the template is approved (capped at `BACKFILL_BATCH` = **500** rows per tick with the approval check memoised per rule, and released on the paced **`redis-broadcast`** lane so a big release never bursts the number) — so people who registered *during* Meta's review still get their welcome.
- **Bridge (QR):** a **free-form body**. Messaging a brand-new number carries ban risk (the UI warns; the engine allows it — there is **no** first-contact gate, see [ai_profile.md](/docs/modules_handbook/manage/messages/whatsapp/ai_profile.md)).

### Back-fill — sending a welcome to people who registered before it existed
A welcome is a **moment-in-time** trigger: it fires once, at registration. So a welcome authored *after* a session already filled up would never reach those registrants — the common "I have 21 people signed up, and I've only just written the welcome" case. (**Reminders need no back-fill**: the scanner re-evaluates every due session on each tick, so a rule created *before* a session's lead-time point fires there by itself — for every registrant, however late they signed up. A rule created **after** that point deliberately does **not** fire for that session: see the catch-up bound above.)

[`BackfillFunnelWelcomeAction`](/app/Actions/BackfillFunnelWelcomeAction.php) is the deliberate catch-up, driven from a **welcome** card's 👥 action → [WhatsappBackfillModal.vue](/resources/js/Pages/Manage/Events/Funnels/Partials/WhatsappBackfillModal.vue):

- **Scoped** — `SCOPE_FUNNEL` / `SCOPE_SLOT` / `SCOPE_SESSION`. A **slot welcome is locked to its own slot**: targeting a sibling slot/session is a **403**, and even the "whole funnel" scope is narrowed to its own slot, so a slot's welcome can never reach another slot's registrants. `target` (a slot / session uuid) is resolved against the rule's **own funnel**, so a uuid from elsewhere 404s rather than widening the audience.
- **Upcoming-only by default** — `include_past` opts back into sessions that already ran (bounded to `PAST_WINDOW_DAYS` = 90, matching the hub's Sessions tab), because a "you're registered" message about a finished webinar is noise.
- **One welcome per person** — registrants are de-duped across sessions (someone on five sessions gets **one** message), then split into *eligible* vs *already had it*. "Already had it" is per-PERSON, not per-rule: back-filling the **funnel-level** welcome also excludes anyone holding a ledger row on any **slot** welcome of the same funnel, because the dispatcher suppressed the fallback for them (a sibling *funnel-level* welcome is not a competitor — those fire together).
- **Cancelled sessions are excluded** — `include_past` means "ran or will run" (`SCHEDULED` or `COMPLETED`), never a called-off session whose registrants have already had its cancellation.
- **Serialised** — the whole run holds a `Cache::lock('funnel-welcome-backfill:{rule id}')`; a second concurrent run is refused with **409** rather than interleaving with the first.
- **Idempotent** — `FunnelWhatsappMessageRepository::claimWelcomeBackfillSend()` refuses any (rule, lead) pair that already has a **live** ledger row, so re-running it, or widening the scope and running again, never double-sends. (The unique index can't enforce it — a welcome's `event_id` is NULL and MySQL lets NULLs repeat — so the check is explicit, which is why the run is locked.) A **FAILED** row is the one exception: it means the send threw, not that the person was greeted, so it is re-queued **in place** (never duplicated) and the back-fill doubles as its retry path.
- **Paced** — sends ride the capped **`redis-broadcast`** lane, exactly like a broadcast, so back-filling a thousand registrants never bursts the number.
- **Pick WHO, not just how many (2026-08-07).** `preview=true` returns the eligible people themselves — `recipients[] {uuid, name, phone, email}` — and the modal lists them with a checkbox each, a search box, and **All / None** acting on the *currently visible* rows (so a search narrows what they touch). **Everyone starts ticked**, so the original one-click "send to all" is still one click; testing on two people is *None* + two ticks, which is the whole reason this exists — a back-fill is irreversible and a first run on a live audience is a bad place to discover a broken token. The button and the SMS/voice cost warnings count the **ticked** total, not the audience.
  - **The posted list is a FILTER, never the audience.** `execute(..., $onlyLeadUuids)` intersects it with the freshly-resolved eligible ids, so a uuid from another slot / funnel — or somebody already greeted — simply falls out; every scope guard stays authoritative whatever the client sends. Sending to **everyone** omits the field entirely rather than posting a list, so the server resolves the audience itself.
  - **The list is capped at `RECIPIENT_LIST_CAP` (500), the COUNT never is.** A 5,000-registrant funnel would otherwise ship 5,000 rows into a modal. Past the cap `recipients_capped` is true, the modal says which slice it is naming, and leaving them all ticked still means *everyone* — narrowing the scope is how you get a pickable list.
  - An **empty** `recipients` array is a 422, not a silent no-op run.
- **Confirmed against a real count** — `preview=true` on the same endpoint resolves the audience *without sending* and returns the picker's options, so the button always reads *"Send to N people"*. A **reminder** is refused (422); so is a **paused** rule or a dead channel (a preview still works, so the admin can see the count before deciding). A rule on a still-pending Cloud template is allowed — the rows are recorded `Skipped(template_pending)` and the scanner releases them on approval, as above.

`FunnelWhatsappController@backfill` (`POST funnel-whatsapp/{id}/backfill`, `throttle:30,1`, [BackfillRequest](/app/Http/Requests/Manage/Events/FunnelWhatsapp/BackfillRequest.php)) is JSON-only; the modal reloads the tab afterwards so each rule's delivery tally reflects what was queued.


### An offset is not a schedule
`60 minutes before the session starts` tells an admin the SHAPE of a rule but not what it will do: whether anything goes out tonight, next week, or never — a slot with no upcoming sessions holds a perfectly valid rule that fires **zero** times, and nothing said so. [`resources/js/utils/reminderSchedule.js`](/resources/js/utils/reminderSchedule.js) (unit-tested) resolves an offset against the funnel's still-to-run sessions (`whatsappSessions`, which now carries `slot_uuid` so a slot-scoped reminder can match), and both surfaces render from it so they can never disagree:

- **The rule form** shows the real dates under the offset editor as it is typed — *"3 sends — first on 29 Jul 2026, 7:00pm"* plus the per-session list.
- **The WhatsApp tab** puts `3× · next 29 Jul 2026, 7:00pm` beside every session-timed rule's timing chip (the full list is the tooltip), and an amber **"nothing scheduled"** when the rule matches no upcoming session.

Arithmetic is deliberately **wall-clock**: `scheduled_date` + `start_time` are stored in the app's timezone, so they are built as local dates and the offset is applied in the same frame — the admin reads the answer on the clock they typed the session time into.

A moment already past `CATCH_UP_MINUTES` is shown struck through as **"too late — won't send"** and excluded from the count, because the scanner genuinely will not fire it; counting it would promise a send that never happens.

### Posting to the funnel's WhatsApp GROUP
A rule's **`audience`** decides who receives it: `AUDIENCE_LEAD` (the default and original behaviour — one message per registered lead) or **`AUDIENCE_GROUP`**, which posts **ONE message into the funnel's WhatsApp group** (`event_funnels.whatsapp_group_link`) no matter how many people registered.

**A group post is scoped to ONE SESSION, not to a slot** (`funnel_whatsapp_messages.event_id`; `event_series_id` is NULL for it, and the two are nulled against each other in `mapInput` so a rule can never be claimed down both branches of the scanner). A per-lead reminder is a *template* — "an hour before each of this slot's sessions, message whoever registered". A group post is not: it lands in a room full of people, most of whom registered for nothing, so what it says is almost always about one particular night. `MoveRequest`-style guards apply at save time — the session must belong to this funnel and must not have finished (a post for a past night could never fire, and a stillborn rule is indistinguishable from a spent one in the list). `RunFunnelReminders::candidateSessions` short-circuits for such a rule, re-checking `event_funnel_id` rather than assuming it: a finished session can be **moved to another funnel**, and a rule left pointing across that boundary would post about the destination's session into the source funnel's group.

**A session rule retires itself.** Once its post reaches a terminal outcome, `SendFunnelWhatsAppMessage::retireSessionRule` sets `is_active = false` (fail-soft — a bookkeeping error must not fail a job whose message already went out). This changes no behaviour, since `group_key` already makes the post unrepeatable; it changes what the tab *says*, so a funnel that has run for months does not show a wall of rules that can never fire again. `sendToGroup` **throws** rather than recording a skip for anything retryable, so reaching the retire step means the outcome really is settled. The WhatsApp tab gives group posts their **own scope tab** (they belong to no slot) and badges a spent one **Done** rather than *Paused*.

**This is a deliberate, narrow exception to the "groups are CAPTURE-ONLY for automation" rule** (see [group.md](/docs/modules_handbook/manage/messages/whatsapp/group.md)). Only a funnel's own session-timed rule may post; the AI reply engine, Flows and the STOP/opt-out handler are still forbidden from ever writing into a group.

- **Bridge only.** The Cloud API cannot address a group at all — a group JID would be mangled into a bogus phone number — so `StoreRequest` rejects a Cloud channel and `mapInput` refuses to persist `AUDIENCE_GROUP` for one.
- **NO tokens, structurally.** The send passes an **empty variable bag**, and `substitute()` resolves an unknown token to an empty string, so there is simply no per-lead value in scope to render. That is the point: a group message is read by everyone in the room, and `{{event_location}}` (one lead's personal Zoom join link) or `{{login_link}}` (a working sign-in credential) would hand one person's private URL to hundreds of people. `StoreRequest` also rejects `{{tokens}}` at save time and the rule form says so.
- **One post can go to SEVERAL groups.** A rule's chosen groups live in `funnel_whatsapp_message_groups` (`FunnelWhatsappMessage::groups()`); **empty keeps the original behaviour** — the funnel's own `whatsapp_group_link` — so nothing already configured changes. The picker (`GET manage/events/funnel-whatsapp/channel-groups`) lists only groups synced through **this** channel, runs each past the same `GroupPostGate` the send uses, and **shows a blocked group disabled with its reason** rather than hiding it (a group an admin expects to see must say why it cannot be used, or a blocked group is indistinguishable from a failed sync). Community **parents** are excluded outright.

  Two things in the modal follow from "the link is the FALLBACK, not a prerequisite", both mirroring `StoreRequest`'s own `$picked === []` condition. The **pre-flight verdict panel and the Save block apply only while nothing is picked** — the verdict describes the funnel's invite-link group and nothing else, so gating on it made every group rule on a community-linked funnel (or on any funnel during a bridge outage) unsaveable even when it named a sub-group the picker itself showed as postable, and the green "Ready to post to …" would name a room the rule never touches. Per-group refusals still disable their own checkbox with their own reason, and `validateGroups` re-checks each picked group server-side. And an **unreachable pick is pruned when the channel's group list arrives**, not when the channel changes: a blanket reset in the watcher also ate the selection an *edit* had just hydrated (hydrating writes `channel` and `audience`, both watched, and Vue runs that pre-flush job after the hydrate callback returns), so every edit silently re-submitted an empty `groups[]`.

  Two things this changes downstream, both load-bearing: `group_key` becomes `"{rule}:{event}:{group}"` so **each group is claimed separately** (keying on the pair alone would let the first group consume the only claim and the rest would never post, silently), and the claimed row carries `whatsapp_group_id` so the send job posts to that group **directly** — no invite link, no bridge call, no ambiguity. `retireSessionRule` also had to learn to wait: retiring on the first send to finish left the siblings finding the rule inactive and recording themselves SKIPPED, so a two-group post reached exactly one group. It now retires only once no QUEUED sibling remains. Covered by `tests/Feature/Event/FunnelGroupPostEndToEndTest.php`.
- **A COMMUNITY link must resolve to its ANNOUNCEMENT group, never the parent.** A community invite resolves to the community **parent**, and a parent is a container, not a chat: its roster holds only the admins (a community of hundreds shows as "7 members"), and WhatsApp rejects a send addressed to it. `FunnelGroupResolver::postableGroup` therefore hops to the sub-group flagged `is_community_announce`, falling back to the parent when none is synced. This is invisible to every check before the send — the invite resolves, a synced group is found, and the gate confirms the channel is a **super-admin** of the parent — and then the post fails at WhatsApp with no reason at all. Proven in production on one number, same day: two normal groups (3 and 19 members) delivered and were read, while every post to the community failed.
- **Guarded at SEND time** by [`GroupPostGate`](/src/Whatsapp/Services/GroupPostGate.php), not only when the rule is saved — admin rights change, and WhatsApp gives no error when an `announce` group silently swallows a non-admin's message. Statuses: `ok` · `not_synced` · `not_member` · `community_parent` · `not_admin` · `stale` · `wrong_provider`. It reads already-synced data (no bridge call): `whatsapp_groups.announce_only` (a column the sync has always written and nothing ever read) plus our own `whatsapp_group_participants.role`.
- **`community_parent` is a gate status, not just a resolver hop.** Every other check *passes* for a container — the number really is its super-admin — and WhatsApp then returns `failed` with an empty payload. The gate refuses it because both unprotected paths reach a parent the same way: `FunnelGroupResolver::postableGroup` deliberately falls back to it when no announcement group is synced, so without this a rule saves cleanly, posts, and fails silently forever. Verified live on this install: the same number's post to the community's announcement group succeeded while the post to the container failed with no reason at all.
- **The gate judges a channel against ITS OWN roster.** The lookup is scoped to `channel_id`: the same JID gets one row per number that is in it, each holding the roster *that* number can see, so an unscoped "freshest row wins" judges one number against another's membership list — and the freshest row is simply whichever number re-synced most recently. Verified live: connecting a new QR number re-synced 28 shared groups and its rosters became the winner for every other number, turning a working `ok / Admin` verdict into a false `not_member` with nothing about the rule having changed.
- **Found by LID first, phone second.** In a large group WhatsApp addresses members by their privacy identity, so an ordinary member's `phone_e164` is NULL — in this install's 1,375-member funnel group **all 1,368 ordinary members are lid-only**. That is why `whatsapp_channels.lid` exists: the bridge always sent `me.lid` and `ChannelInfoSync::applyBridgeIdentity` used to discard it, so a phone-only match would wrongly report "not a member" in exactly the group the guard is for.
- **STALE is permissive.** Leaving a group emits no Baileys event, so an untouched roster is not proof of membership; after `STALE_AFTER_DAYS` (14) the verdict says so, but still allows the post rather than silently stopping a working automation.
- **De-duped by `funnel_whatsapp_sends.group_key`** (`"{rule}:{event}"`, unique). The ledger's `UNIQUE(message, lead, event)` cannot help — a group post has no lead and MySQL treats the NULL as distinct — so without this the five-minute scanner would **re-post the same message into the group on every tick**. `lead_id` is nullable for the same reason.
- **The duplicate is caught on the ROOM, at send time — never on the key shape.** `"{rule}:{event}"` (no groups picked → the funnel's invite link) and `"{rule}:{event}:{group}"` are separate namespaces for what is usually the *same room*, since the link resolves to a group the picker also offers. Editing an armed rule's "Post into" list mid-session therefore mints a key the ledger has never seen, and the room would get the identical message twice — unticking the last group does it in reverse.

  Three things make the check honest. First, **a delivered post pins the room it reached**: `recordResult` takes the resolved group, because a fallback claim carries none — nothing knows where the invite link points until the job has resolved it, so without this the ledger holds a delivered post whose destination is unknowable afterwards. Second, the match is on the **JID, never on `whatsapp_group_id`** — a `whatsapp_groups` row is one (channel × room) pair, so the same room has a row per number that is in it, and comparing row ids would see two rooms where there is one. Third, "already reached" excludes a message the provider **rejected**: `whatsapp_message_id` is written when the outbound row is created, not when WhatsApp accepts it, so a failed post would otherwise block its own retry for good.

  A guard at CLAIM time can have none of the three. Keyed on the key shape it answers "was the other targeting mode used?", which is a different question — a fallback post into room A would refuse a later, entirely legitimate claim for room B, and refuse it **invisibly**, since a claim that never happens writes no ledger row, fires no alert and logs nothing (`RunFunnelReminders` simply does `if ($send)`). Delivery, not merely an attempt, is the bar: re-picking the group is exactly how an admin recovers from a refused post. The lane is serialised (`supervisor-broadcast`, `maxProcesses: 1`), so two claims for one room are never in flight together.

  `SKIP_ALREADY_POSTED` is **excluded from the tab's `problems` count and from `attachProblemReasons`** — every other skip is a problem, so counting a deliberate no-op would badge a rule that did exactly the right thing, and the reasons query keeps the newest problem row per rule, so a benign one would be printed as the explanation for an older real failure. That is the same false alarm that sends an admin hunting for a broken group. The consequence, stated plainly: the row is an **audit trail in the ledger, not a screen** — there is no per-rule ledger drawer today, so the rule simply reads "1 posted, no problems", which is the truth.
- **`FunnelGroupResolver` prefers the SENDING number's copy of a room.** It used to take the freshest `whatsapp_groups` row for the JID across every channel, so which row won changed each time an admin connected a QR number — the conversation was then opened against a stranger's copy, and the picker (which lists only rows synced through this channel) named a different row for the same room. Same root cause as the `GroupPostGate` channel scoping above. It still falls back to any row for the JID, so a number that has not synced the group gets the informative `NOT_SYNCED` verdict and a recorded skip rather than an unresolvable link that throws and retries.
- **A failure is recorded AND alerted.** The row is Skipped (`no_group` / `group_forbidden`) and the `events.group_post_blocked` Notify event pushes a Telegram message to subscribed admins — a missed group post is otherwise invisible, since nobody is waiting for a specific message. The alert is **scoped per blocked post** (`group-post:{send}`): the event's throttle is an hour, and without a scope that hour is *global*, so the second blocked post of the hour — another group, another rule, another funnel — is dropped. The config's "stop the five-minute scanner machine-gunning the phone" rationale does not apply here, because a skip burns the one-shot claim and the scanner can never re-reach the post.
- The funnel hub's **Group tab** shows the verdict per connected QR number before an admin ever authors a rule (`FunnelsController::postingCapability`).
- **Also checked BEFORE the rule can be saved**, in two matching places, so the composer never accepts a message the sender will refuse:
  - `GET manage/events/funnel-whatsapp/group-eligibility?funnel=&channel=` (`FunnelWhatsappController@groupEligibility`) — the rule modal calls it the moment "the funnel WhatsApp group" is chosen (and on every channel switch), and shows *Ready to post to "<group>" — this number is Admin there*, or the exact blocker with a **Check again** button. **Save is disabled** while a checked verdict says the number cannot post.
  - `StoreRequest::validateCanPost()` — the server's own copy, so the API is guarded whatever the UI does.

  Both **fail OPEN when the group cannot be resolved** (bridge down, revoked invite link): refusing then would make an existing group rule impossible to edit — or even to switch off — for as long as the outage lasts, and the send-time gate plus its Telegram alert already cover the actual post. Only a verdict that was genuinely established (`not_member` / `not_admin` / `wrong_provider`) blocks the save.
- **The composer hides all token affordances for a group rule** — the `+ {{token}}` chips, the "What will each token become?" cheat-sheet and the `{{name}}` placeholder — because nothing is ever substituted there. Offering them would only invite a save the server rejects.

### After the session starts
**`TRIGGER_AFTER_EVENT`** is the follow-up half of a reminder: post the replay link, ask for feedback, push the next class. It reuses `offset_minutes`, which stays an **unsigned** distance from the session start — the TRIGGER carries the direction, so nothing has to interpret a negative number.

**`TRIGGER_AFTER_ENDED` (4)** is the wrap-up: timed from when the webinar **ACTUALLY ends** (`zoom_webinars.ended_at`, stamped by the `webinar.ended` webhook — never the scheduled `end_time`, which a live session can run far past; a physical session falls back to its scheduled end). The scanner resolves the anchor via `SessionDueWindow::endedAt()` and **HOLDS while the webinar is still LIVE** — nothing is claimed, the next tick retries. Same unsigned offset + catch-up band.

The scanner needs a genuinely different query for it: a BEFORE rule uses `Event::upcoming()`, but an AFTER rule's session has already STARTED, so `upcoming()` would exclude exactly the rows wanted. `candidateSessions()` instead centres on `start + offset` and accepts **COMPLETED** as well as SCHEDULED (the Zoom webhook flips a finished session to COMPLETED) but never CANCELLED. The same `CATCH_UP_MINUTES` band applies, so a follow-up whose moment passed hours ago is skipped rather than posted late.

**The no-show nudge (`no_show_only`).** An AFTER rule may additionally be flagged **no-shows only** (the classic use: *15 minutes after the webinar starts*, "the class has started — here's your link") — it then reaches **only the registrants with no live-attendance row**, judged against `zoom_webinar_attendances`, which the `webinar.participant_joined` **webhooks feed in real time** (no extra Zoom API call). Semantics, in the scanner and the send job:

- **Attendees are claimed `Skipped(attended)`** (a new terminal `SKIP_ATTENDED` reason) — the verdict is recorded once per (rule, lead, session) and the analytics explain it.
- **The event is HELD while the webinar has not started** (webinar missing, or status not LIVE/ENDED): an empty attendance table would read *everyone* as a no-show, so nothing is claimed and the next 5-minute tick retries inside the catch-up band. If the started webhook never lands, the nudge silently doesn't fire — the safe failure.
- **A last-moment recheck in `SendFunnelWhatsAppMessage`**: the broadcast lane is paced, so someone who joins between the scanner's claim and their turn in the queue is spared (recorded `Skipped(attended)`).
- **Zoom slots + per-lead audience only** (validated at save): a physical session has no live join feed (its door check-in is a different system), and a group post has no per-lead dimension. Ticking the box on a new rule presets the offset to **15 minutes**.

### After they REGISTER — the Video Sales Letter ladder (2026-08-13)

**`TRIGGER_AFTER_REGISTER` (5)** is the only timed trigger anchored on a PERSON rather than a session: `lead_funnels.registered_at + offset_minutes`. It exists because a **VSL funnel has no sessions at all** — so before this, every timed rule an admin could author on one matched nothing and fired for nobody, silently, since the scanner simply found no candidate rows. Only the welcome ever worked.

It carries two **conditions**, and they are what make a ladder possible rather than a blast:

| Column | Values | Meaning |
|---|---|---|
| `watch_condition` | `WATCH_ANY` / `WATCH_NONE` / `WATCH_UNDER_HALF` / `WATCH_OVER_HALF` | how far into the video they got |
| `booking_condition` | `BOOKING_ANY` / `BOOKING_NOT_BOOKED` / `BOOKING_BOOKED` | whether they have a 1-on-1 |

Both are **nullable, and NULL means ANY** — which is what every session-timed rule is, so nothing already stored changed meaning. `watchCondition()` / `bookingCondition()` resolve the null so no consumer has to remember it.

A worked ladder (the Cochrane shape): a welcome carrying `{{video_link}}`, then at **45 min** three rules — 没看 / 看不到一半 / 看超过一半, all `还没约` — then one at **1 day**, any depth, still `还没约`.

**The three watch buckets PARTITION everyone.** That is the property the whole design leans on: a person is always in exactly one of them, so three rules at one moment reach the entire funnel exactly once. It is why an unmeasurable percentage (the player never reported a duration) counts as UNDER half rather than matching nothing — a null that fell through every bucket would leave a cohort unreachable from any rule while the roster still counted it, and over-crediting attention is the error that costs a call.

- **ONE judge: [`Src\Event\Support\VslLeadState`](/src/Event/Support/VslLeadState.php).** `watchBucket()` / `mergeProgress()` / `bookedLeadIds()` / `matches()`. It exists because that question is now asked from four places — the roster's **影片进度** column and its filter chips, the Summary waterfall, this scanner, and the send job's last-moment re-check — and the handbook already records what happens when such a rule is re-implemented per caller (the funnels index once read *93 booked* for a funnel nobody had messaged). `FunnelsController::vslLeads()` folds its video rows through the **same** `mergeProgress`, so the percentage a rule fires on is byte-for-byte the one the admin reads on the row. ⚠️ `watchBucket` is mirrored in JS by [`useVslRosterFilters.js`](/resources/js/composables/useVslRosterFilters.js) — same two row fields, same thresholds; change one and you must change both.
- **Multi-device:** `funnel_video_views` is unique on (funnel, **visitor_key**), a SESSION value, so one person on a phone and a laptop is TWO rows. The **longest watch wins**, and the completion percentage comes **from that same row** (mixing one device's numerator with another's denominator is arithmetic nobody can explain when it disagrees with the screen). The reads are `ORDER BY watched_seconds, id` so the fold is deterministic on a tie.
- ⚠️ **已经约了 counts `whatsapp_opened_at` OR `appointment_at`** (product decision 2026-08-13). The first is the customer's own act — the only thing the roster's 已约 1-1 card counts, because `scheduleVslLead()` MINTS a `consultation_requests` row when an admin assigns a caller, so counting rows counts our own follow-up work as demand. The second is added **here only**: a caller who fixed a time over the phone has an appointment, and "要不要约个 1-1?" landing an hour later is exactly what the condition is for.

**The scanner** — a third pass inside [`RunFunnelReminders`](/app/Console/Commands/RunFunnelReminders.php) (`dispatchRegisterRules`), same 5-minute tick:

- **Catch-up is `REGISTER_CATCH_UP_MINUTES` = 180**, not the session band's 60. The two guard different lies: *"your session starts in 24 hours"* becomes false the instant its moment passes; *"you registered yesterday — did you finish the video?"* is still true three hours later. The narrow band would drop a whole cohort's follow-up to a two-hour scheduler outage.
- **NEVER RETROACTIVE.** A registration whose moment had already passed when the rule was created is excluded (`registered_at >= rule.created_at − offset`), exactly as a session reminder authored past its lead-time point does not fire for that session. Without it, saving a "1 day after registration" rule immediately messages everybody who signed up yesterday — a blast with no undo.
- **LANDING REGISTRATIONS ONLY** (`marketing_source = FUNNEL`) — the same cohort rule the Dashboard and the VSL Summary apply, and here also a safety rail: imported people arrive in one batch, so without it a single CSV import becomes an unannounced blast on a billed official channel.
- **The verdict is RECORDED, not skipped.** A person who does not match still gets a claimed ledger row, `Skipped(condition_not_met)`. This is load-bearing: watch depth keeps moving, so leaving the other rules unclaimed would let the next tick re-examine them and fire a SECOND message the moment somebody crossed halfway, fifteen minutes after the first.
- **The funnel's own toggle is the kill switch** — a VSL has no sessions to cancel, so pausing the funnel is the only lever an admin has and it must actually work.
- A Cloud rule on an unapproved template **HOLDS** (nothing claimed), releasing itself on approval like a reminder.

⚠️ **The de-dupe needed its own key.** The ledger's `UNIQUE(message, lead, event)` cannot help: these sends carry a NULL `event_id` and **MySQL treats NULLs as distinct**, so the five-minute scanner would re-claim and re-send to the same person on every tick. Hence **`funnel_whatsapp_sends.lead_funnel_id` + `UNIQUE(message, lead_funnel)`** (migration `2026_08_13_100003`) and `claimRegisterSend()` — the same trap the GROUP audience hit, and the same shape of fix as its `group_key`. The registration row is the right key rather than the lead, because it is what carries `registered_at`.

**Send-time re-check.** `SendFunnelWhatsAppMessage` re-reads the BOOKING for a `BOOKING_NOT_BOOKED` rule — the broadcast lane is paced, so minutes pass between claim and send — and records `Skipped(already_booked)`. ⚠️ The WATCH condition is deliberately **not** re-checked: it was judged at the due moment, and that verdict is what decided which of the three bucket rules claimed this person, so re-judging could only cancel a message whose siblings had already stood down, leaving them with nothing.

**Two new skip reasons, both NO-OPS.** `SKIP_CONDITION` and `SKIP_BOOKED` join `SKIP_ALREADY_POSTED` in **`FunnelWhatsappSend::NON_PROBLEM_SKIPS`**, which `ruleSendStats()` and `attachProblemReasons()` now read instead of naming one reason each — a new no-op cannot be added in one place and forgotten in the other. Counting them would badge a rule that behaved correctly, which is the false alarm that sends an admin hunting for a fault that is not there.

**Tokens.** `{{video_link}}` (the funnel's `/{slug}/video`), `{{watched_minutes}}`, `{{completion_percent}}` — resolved in `FunnelWhatsappComposer::assemble()` for VSL funnels only, so a webinar funnel pays no query for them. The rule modal swaps its whole token picker for a VSL funnel (`VSL_TOKENS`): **every event token renders empty there**, so offering them would only invite a message with four blanks in it. A test send samples the two progress tokens (there is no lead behind a preview) but never `{{video_link}}`, which is already real.

**Reaching the people who registered BEFORE the rule existed** — [`BackfillVslFunnelMessageAction`](/app/Actions/BackfillVslFunnelMessageAction.php), the 👥 action on a VSL rule's card. Because the scanner is never retroactive, this is the **only** way those people are ever messaged, so it covers BOTH kinds of VSL rule (the welcome and the follow-ups) rather than the welcome alone.

> ⚠️ **It exists because the session back-fill silently found nobody.** [`BackfillFunnelWelcomeAction`](/app/Actions/BackfillFunnelWelcomeAction.php) resolves its audience from `events` → `event_registrations`, which is right for a webinar funnel and resolves to **zero** for a video one — a VSL has no sessions. On the live Cochrane funnel the button therefore reported *0 people* for a funnel with ninety-odd registrants, from the day VSL funnels shipped, and nothing said why. Pinned by `test_the_session_backfill_finds_nobody_on_a_video_funnel_but_this_one_does`.

- **The audience is `lead_funnels`** — landing registrations only (`marketing_source = FUNNEL`), the same cohort rule as everywhere else and the rail that keeps one CSV import out of a manual blast.
- **Conditions are re-judged as of NOW** (product decision 2026-08-13). A follow-up saying "you're already past halfway" must reach the people who are past halfway *today*. Since the buckets partition, backfilling all three rules of one moment reaches each person exactly once, and the modal states the chain — **registered → match now → already messaged → will receive** — because a bare "7" is a number an admin has to trust while the chain is one they can check.
- **"Already had it" spans the SIBLING RULES OF THE SAME OFFSET**, not just this rule. Somebody who watched more since their first follow-up now matches a different bucket; without this they are told about the same thing twice. Scoped to the same offset on purpose — a 45-minute message must not block the one-day chase.
- ⚠️ **A recorded stand-down is NOT a message.** `condition_not_met` (and a FAILED row) are re-queued **in place** by `claimRegisterBackfillSend`, because that row means "the rule was not for them *then*" — which is precisely the person this feature exists to reach. A REAL skip (`no_phone`, `opted_out`) is left alone: re-sending would only skip again for the same reason.
- **Narrowed by DATE, not by scope** — a VSL has no slots or sessions to pick, so the modal offers 全部 / 7 / 30 / 90 days, defaulting to **everyone** and resetting to it on every open (a window carried across opens would silently under-send). Everything else is the session back-fill's contract, shared through [`Concerns\PicksBackfillRecipients`](/app/Actions/Concerns/PicksBackfillRecipients.php): the same per-person picker, the same **"a posted list is a FILTER, never the audience"** intersect, the same per-rule lock (409 on a concurrent run) and the same paced `redis-broadcast` lane.

Covered by [`tests/Feature/Event/VslFunnelBackfillTest.php`](/tests/Feature/Event/VslFunnelBackfillTest.php).

**Scope + UI.** VSL funnels only — [`StoreRequest::validateRegisterRule`](/app/Http/Requests/Manage/Events/FunnelWhatsapp/StoreRequest.php) refuses the trigger on a webinar funnel (its conditions read tables that have no rows there, so every rule would match nobody or everybody while looking armed) and refuses a posted slot; a condition on any other trigger is refused too, since only this scanner reads it. **WhatsApp only** — `FunnelAutomationMessage` reserves the value 5 in a comment but does not declare it, because that table's scanner has no register-anchored pass and a rule saved against it would fire for nobody. The Automation tab swaps the five-step session timeline for a **two-step VSL journey** (① when they register → ② after they register), and warns when two active rules at one offset have overlapping watch conditions — **both still send** (product decision 2026-08-13: an admin sometimes wants a text plus an image, and a rule that silently refuses to fire is the harder fault to find), so the only thing that makes it safe is saying so first. `RuleRow` renders the conditions as chips from the server's own labels.

Covered by [`tests/Feature/Event/VslFunnelAutomationTest.php`](/tests/Feature/Event/VslFunnelAutomationTest.php) — buckets reach exactly one person each, the stand-down verdicts are recorded, three consecutive scans send once, the never-retroactive guard, imported leads excluded, a paused funnel stops, both booking facts stand a nudge down, a bare consultation row does NOT, the send-time re-check, the tokens, and both validation refusals.

### SMS fallback — when WhatsApp delivery FAILS (2026-08-07)

A per-lead rule may opt into **`sms_fallback`**: when its WhatsApp send **fails to deliver**, the
person gets ONE SMS with the rule's own **`sms_fallback_body`** (same `{{token}}`s, capped at
3 billed GSM segments) — written by the admin, because a Cloud template's content lives at Meta
and cannot be auto-converted to text.

**"Failed" means failed, not unread — by design** (product decision 2026-08-07): the ledger row
is FAILED (rejected at send time), **or** SENT with the outbound message later FAILED by the
async status webhook — which is exactly how **Meta's per-recipient marketing cap (131049)**
lands. Delivered-but-unread never falls back (that person's WhatsApp works; SMS-ing them too is
double cost + double noise), and skips (`no_phone` / `opted_out`) have nothing to rescue.

Mechanics, all in [`RunFunnelReminders::dispatchSmsFallbacks`](/app/Console/Commands/RunFunnelReminders.php)
+ [`SendFunnelSmsFallback`](/app/Jobs/Automation/SendFunnelSmsFallback.php):
- The 5-minute scanner sweeps for eligible failures and **claims each atomically** on the
  WhatsApp send row (`sms_fallback_at`, update-where-NULL) — a failure falls back at most once,
  and the outcome lands on `sms_fallback_status` (`sent` / `failed` / `skipped:*`).
- **Bounded to the last 24 h** — enabling the fallback on a rule with months of historical
  failures must never turn into a retroactive SMS blast.
- The SMS goes to the **same number the WhatsApp send was addressed to** (incl. its per-send
  `deliver_to` override), rendered through the same `FunnelWhatsappComposer`.
- A **BEFORE-session** reminder whose session is already Past/Cancelled is
  `skipped:session_over` — a "starts soon!" rescue after the fact is noise. AFTER-session rules
  naturally describe a past session and are not guarded.
- **Group posts are excluded** (no per-person delivery to fall back from) — refused at save
  time too. A soft-deleted or paused rule's failures are never rescued.

Pinned by [FunnelSmsFallbackTest](/tests/Feature/Event/FunnelSmsFallbackTest.php) — which also
pins that `sms_fallback` / `sms_fallback_body` **persist through the endpoint** (the repo's
`data_only` whitelist trap: `no_show_only` was missing from it since the nudge shipped, so the
modal's checkbox silently saved rules that were never actually no-shows-only — fixed in the
same change).

### Test send (admin) — receive it before a lead does
The WhatsApp tab's **Test** button (and a per-rule ✈️ shortcut) opens `TestSendModal`: pick one automation, enter a recipient phone, optionally a test name → **receive the real rendered message(s) on WhatsApp**. It uses **real sample data** — the funnel's *next upcoming session* for the event tokens (a **slot-scoped reminder samples its own slot's** next session — `FunnelWhatsappComposer::variablesForTest($funnel, $admin, $name, $seriesId)`) + the **admin's own** working `auth.magic` sign-in link for `{{login_link}}` + the typed name — so it is byte-for-byte what production sends. The modal also shows a **live preview** (server-rendered, `preview=true` on the same endpoint — no phone, no send). Delivery reuses the exact pipeline (`conversationFor` + `createOutbound` + `SendWhatsAppMessage`, respecting the multi-step pacing) but: writes **no `funnel_whatsapp_sends` ledger row**, tags each message `meta.funnel_whatsapp.test = true`, and **deliberately bypasses the opt-out / blocked gate** (an explicit admin send to a typed number). `FunnelWhatsappController@test` (`POST funnel-whatsapp/{id}/test`, `throttle:30,1`, [TestRequest](/app/Http/Requests/Manage/Events/FunnelWhatsapp/TestRequest.php)) builds via the shared **`FunnelWhatsappComposer`**, so the production job and the test render identically. Cloud rules test the approved template; Bridge rules test the full step sequence. (Live delivery still needs Horizon + the Bridge running.)

### Admin UI
The funnel hub (`Funnels/Show.vue` → `ShowTabs`) gains a **WhatsApp** tab ([WhatsappTab.vue](/resources/js/Pages/Manage/Events/Funnels/Partials/Tabs/WhatsappTab.vue)) organised **by scope, behind scope tabs** (*Whole funnel* | one tab per slot, each with a rule-count badge; no header blurb): the **Whole funnel** tab holds its welcome (funnel-landing registrations + slots without their own), and each **slot tab** lays out the lead's journey — *① When they register* (the slot's own welcome, or an inline *"Uses the whole-funnel welcome"* inherited state that teaches the precedence in place, linking back to the funnel tab) → *② Before each session* (its reminders, furthest lead time first). Every add is **contextual** — the "+" inside a group opens the modal with the trigger + slot **pre-filled and locked** behind a context banner, so the admin only picks the channel + content; rules that can never fire (a slot-less legacy reminder, or a rule whose slot was deleted) sit in an amber **Needs attention** group. Each rule card keeps test / pause / edit / remove (the preview shows the first step `(+N more)`). An unlocked add/edit still exposes the full pickers, incl. the welcome's **"For"** scope picker (*Whole funnel — the funnel landing page* default, or one slot's landing). The content editor carries a **token cheat-sheet** (a collapsible *"see example values"* table mapping each `{{token}}` to a sample — the funnel name is real; hovering a step's token chip shows its example too), in both the Bridge step builder and the Cloud template-params branch.

**Composing (shared with the Templates page).** The modal is wide (6xl) with a **live WhatsApp-bubble preview** on every path. A **Cloud** channel offers *Use existing template* (the picker lists **approved AND pending** templates with status badges + a `Components/Whatsapp/TemplatePreview` bubble of the selection) **or *Create new template* inline** — the shared **`Components/Whatsapp/TemplateDesigner`** (the same builder+preview the Templates page modal uses, extracted) in **named-token mode**: the admin writes `{{name}} / {{event_title}} …` directly and on *Submit to Meta* the modal **auto-converts** them to Meta's positional `{{1}}…{{n}}`, auto-fills Meta's review samples from the token examples, POSTs `manage/whatsapp/templates` (the endpoint answers **JSON** to axios callers), then **auto-selects** the new (pending) template with the rule's `template_params` pre-wired (`{{1}}→{{name}}` …). Named mode hides per-variable sample inputs and **disallows header variables** (the funnel pipeline fills body params only). A **Bridge** channel keeps the **multi-step builder** (its one-shot multi-message ability is QR-only) beside a **`Components/Whatsapp/BridgeStepsPreview`** — one chat bubble per step, tokens highlighted; **Footer/Buttons never appear** (Cloud template concepts a QR number can't send). Add/edit is a modal ([WhatsappRuleFormModal.vue](/resources/js/Pages/Manage/Events/Funnels/Partials/WhatsappRuleFormModal.vue)): picking the *Before a session* trigger reveals a **required "For slot" picker** (the funnel's slots, by **title**; validated server-side to belong to the funnel — `StoreRequest` `slot` required_if + `withValidator` ownership check). The content editor is **provider-aware** — a Cloud channel shows the approved-template picker (+ a body-variable input per template variable, with an *available tokens* hint); a Bridge channel shows the **step builder**: up to 5 ordered *Text message* / *Media message* cards (reorder / remove; per-step `{{token}}` insert chips; media uploads via a silent axios `POST funnel-whatsapp/media` — stored on GCS against the funnel, mirroring the flow step builder). A legacy single-`body` rule hydrates as one text step. The reminder lead time is a friendly value + unit (minutes / hours / days) mapped to `offset_minutes`.

### Did it actually reach them? — delivery analytics
Two surfaces, both counting `FunnelWhatsappSend::outcome()` (ledger + the outbound message's delivery state), so "sent" never gets mistaken for "arrived":

- **Per session — the Show page's Registrations roster** (the standalone `Messages` tab merged into it 2026-08-03; `MessagesTab.vue` deleted — the prop is still built by `EventsController::buildMessagesProp` and threaded to [RegistrationsTab.vue](/resources/js/Pages/Manage/Events/Partials/Tabs/RegistrationsTab.vue)). The roster carries a **Messages column**: one compact chip per rule in scope for that session (the reminders on its slot + the welcomes that could have greeted its registrants — the slot's own AND the whole-funnel one, since which fired depends on the landing they came through), each showing the outcome (**Queued / Sent / Delivered / Read / Failed / Skipped**, the skip reason + timestamp on hover, a dimmed chip where the rule hasn't fired yet). Above the table: headline **reached** / **read** counts and a **problems-only** toggle that narrows the roster to people with a failed or skipped message — the pre-class check of *who did NOT get the reminder*. The column and headline only appear when the session's funnel has rules in scope. Two queries total (rules + one ledger fetch with `whatsappMessage` eager-loaded).
- **Per rule, lifetime** — each card on the funnel's **WhatsApp tab** shows `N sent · N reached · N read · N problem`, from `FunnelsController::ruleSendStats()` — **one grouped query** (ledger status × message status, folded in PHP), so a funnel with tens of thousands of sends still costs a single aggregate.

## GUIDELINES alignment
- **§2/§3** — all writes via [`FunnelWhatsappMessageRepository`](/src/Event/Repositories/FunnelWhatsappMessageRepository.php) in `DB::transaction` (create/update/setActive/delete + the ledger `createWelcomeSend` / `claimReminderSend` / `claimWelcomeBackfillSend` / `recordResult`); thin [`FunnelWhatsappController`](/app/Http/Controllers/Manage/Events/FunnelWhatsappController.php) with explicit, provider-aware input mapping; validation in Form Requests.
- **§3 constants** — `TRIGGER_*` / `TRIGGERS`, send `STATUS_*` / `STATUSES`; no magic numbers.
- **§7** — key model (`FunnelWhatsappMessage`: uuid + blame + soft delete) + an append-only child ledger (no uuid), snake_case ≤30-char columns, indexed FKs, no schema FKs, the composite unique for de-dupe; a new migration only.
- **§14 / cross-module** — the rules live on the funnel hub (posted `funnel`, resolved by the now-STRICT `ResolvesFunnel` — there is no default-funnel fallback, so `FunnelWhatsappController@store`/`update` `abort_if` 404 when no funnel resolves); the send engine is entirely reused from `Src\Whatsapp`.

## Related files

**Backend — Models + Repository** (`src/Event/`)
- [FunnelWhatsappMessage.php](/src/Event/FunnelWhatsappMessage.php) (`steps()` / `MEDIA_COLLECTION` / `MAX_STEPS`) · [FunnelWhatsappMessageStep.php](/src/Event/FunnelWhatsappMessageStep.php) · [FunnelWhatsappSend.php](/src/Event/FunnelWhatsappSend.php) · `EventFunnel::whatsappMessages()`.
- [Repositories/FunnelWhatsappMessageRepository.php](/src/Event/Repositories/FunnelWhatsappMessageRepository.php) — rule CRUD (+ `replaceSteps`, group-replaced on save) + ledger claim/record (incl. the idempotent `claimWelcomeBackfillSend`).

**Backend — Trigger + schedule + send**
- [app/Actions/DispatchFunnelWelcomeAction.php](/app/Actions/DispatchFunnelWelcomeAction.php) — the welcome fan-out, called from [app/Actions/RegisterLeadAction.php](/app/Actions/RegisterLeadAction.php).
- [app/Actions/BackfillFunnelWelcomeAction.php](/app/Actions/BackfillFunnelWelcomeAction.php) — the scoped, idempotent **back-fill** of a welcome to people who registered before the rule existed, resolved from SESSION registrations (`SCOPES` / `PAST_WINDOW_DAYS`, `preview()` / `execute()` / `scopeOptions()`). ⚠️ Blind to a VSL funnel by construction — it has no sessions.
- [app/Actions/BackfillVslFunnelMessageAction.php](/app/Actions/BackfillVslFunnelMessageAction.php) — its VIDEO SALES LETTER counterpart: the audience comes from `lead_funnels`, the conditions are re-judged as of NOW, and "already had it" spans the sibling rules of the same offset. Serves the welcome AND the follow-ups, since neither is ever retroactive.
- [app/Actions/Concerns/PicksBackfillRecipients.php](/app/Actions/Concerns/PicksBackfillRecipients.php) — the half both share: `RECIPIENT_LIST_CAP`, the named recipient rows, and the rule that a posted list is a **filter, never the audience**.
- [app/Console/Commands/RunFunnelReminders.php](/app/Console/Commands/RunFunnelReminders.php) — `whatsapp:run-funnel-reminders` (scheduled in [app/Console/Kernel.php](/app/Console/Kernel.php)).
- [src/Event/Support/FunnelWhatsappComposer.php](/src/Event/Support/FunnelWhatsappComposer.php) — **the shared content builder** (token variable bags `variablesForSend` / `variablesForTest` + provider-aware `inputs()`); pure (no queue), used by both the send job and the Test endpoint.
- [src/Event/Support/VslLeadState.php](/src/Event/Support/VslLeadState.php) — **the ONE judge** of "how far into the video did this person get, and have they asked for a 1-on-1?" (`watchBucket` / `mergeProgress` / `blankProgress` / `forLeads` / `bookedLeadIds` / `hasBooked` / `matches`). Read by the scanner, the send job's re-check and `FunnelsController::vslLeads()`; mirrored in JS by `useVslRosterFilters.js`.
- [src/Event/Support/OffsetLabel.php](/src/Event/Support/OffsetLabel.php) — how an `offset_minutes` is WORDED (`amount()` → "1 day", `short()` → "1d"). Shared by the session Registrations roster, the VSL roster and the Automation tab, so one rule cannot read "1d" on one screen and "1440m" on another.
- [src/Event/Support/FunnelGroupResolver.php](/src/Event/Support/FunnelGroupResolver.php) — turns a funnel's `whatsapp_group_link` (a `chat.whatsapp.com` **invite link**, a public join code) into the synced `WhatsappGroup` a message can actually be addressed to: `inviteCode()` (static, the **single** implementation — the funnel hub's Group tab (`FunnelsController@groupStatus`) calls the same method, so a link either resolves for both or for neither) → `jidForFunnel()` (one `groupGetInviteInfo` bridge call, which resolves **without joining**; cached `CACHE_MINUTES` = 60, and a bridge failure resolves to nothing rather than caching a permanent "no") → `forFunnel()` (the freshest synced row for that JID). Load-bearing for the whole GROUP audience — used by the send job, `StoreRequest`'s save-time pre-flight and `FunnelWhatsappController@groupEligibility`.
- [src/Whatsapp/Services/GroupPostGate.php](/src/Whatsapp/Services/GroupPostGate.php) — the shared **"may this number post into that group right now?"** verdict (`check()` / `allows()`; `OK` · `NOT_SYNCED` · `NOT_MEMBER` · `COMMUNITY_PARENT` · `NOT_ADMIN` · `STALE` · `WRONG_PROVIDER`, with `REASONS` as the one wording source for the send failure, the eligibility endpoint and the Group tab). Answered from already-synced data only — our own participant row found by **LID first, phone second**, `whatsapp_groups.announce_only`, and `STALE_AFTER_DAYS` (14, permissive). Owned by the [WhatsApp · Groups](/docs/modules_handbook/manage/messages/whatsapp/group.md) handbook; consumed here at send time, at save time and by the funnel hub's Group tab.
- [app/Jobs/Whatsapp/SendFunnelWhatsAppMessage.php](/app/Jobs/Whatsapp/SendFunnelWhatsAppMessage.php) — the provider-aware send (composes via `FunnelWhatsappComposer`, paces `createOutbound` + `SendWhatsAppMessage`).

**Backend — Controller + Requests + Routes**
- [app/Http/Controllers/Manage/Events/FunnelWhatsappController.php](/app/Http/Controllers/Manage/Events/FunnelWhatsappController.php) — store / update / toggle / destroy + `uploadMedia` (JSON, step-builder uploads — returns a signed `url` + `is_image` so the composer previews the real attachment) + `mapSteps` + **`test`** (live test send / preview) + **`backfill`** (welcome catch-up / audience preview) + **`groupEligibility`** (the group pre-flight verdict).
- [app/Http/Requests/Manage/Events/FunnelWhatsapp/StoreRequest.php](/app/Http/Requests/Manage/Events/FunnelWhatsapp/StoreRequest.php) (+ `UpdateRequest`, [UploadMediaRequest](/app/Http/Requests/Manage/Events/FunnelWhatsapp/UploadMediaRequest.php), [TestRequest](/app/Http/Requests/Manage/Events/FunnelWhatsapp/TestRequest.php), [BackfillRequest](/app/Http/Requests/Manage/Events/FunnelWhatsapp/BackfillRequest.php)) — provider-conditional content validation (Cloud → approved template; Bridge → ≥1 sendable step or the legacy body).
- [app/Http/Controllers/Manage/Events/FunnelsController.php](/app/Http/Controllers/Manage/Events/FunnelsController.php) — `show` feeds the WhatsApp tab (rules + channels + approved templates + triggers).
- [routes/web.php](/routes/web.php) — `manage.events.funnel-whatsapp.*`.

**Frontend (Vue)**
- [resources/js/Pages/Manage/Events/Funnels/Partials/Tabs/WhatsappTab.vue](/resources/js/Pages/Manage/Events/Funnels/Partials/Tabs/WhatsappTab.vue) (rule list + **Test** button + per-rule ✈️) · [Partials/WhatsappRuleFormModal.vue](/resources/js/Pages/Manage/Events/Funnels/Partials/WhatsappRuleFormModal.vue) (step builder + the group pre-flight panel; token affordances hidden for a group rule) · [Components/Whatsapp/BridgeStepsPreview.vue](/resources/js/Components/Whatsapp/BridgeStepsPreview.vue) (live bubble preview — renders the uploaded image itself, falling back to a placeholder for a video / document / expired signed URL) · [Partials/TestSendModal.vue](/resources/js/Pages/Manage/Events/Funnels/Partials/TestSendModal.vue) (test send + live preview) · [Partials/WhatsappBackfillModal.vue](/resources/js/Pages/Manage/Events/Funnels/Partials/WhatsappBackfillModal.vue) (welcome back-fill: scope picker + live count + the **recipient picker** — every eligible person listed with a checkbox, searchable, all ticked by default; serves every medium) · wired into [Funnels/Show.vue](/resources/js/Pages/Manage/Events/Funnels/Show.vue).

**Migration + tests**
- [database/migrations/2026_07_01_000020_create_funnel_whatsapp_tables.php](/database/migrations/2026_07_01_000020_create_funnel_whatsapp_tables.php).
- [database/migrations/2026_07_02_000040_add_whatsapp_group_link_to_event_funnels_table.php](/database/migrations/2026_07_02_000040_add_whatsapp_group_link_to_event_funnels_table.php) — the per-funnel `whatsapp_group_link` (the `{{whatsapp_group_link}}` token source).
- [database/migrations/2026_07_02_000050_create_funnel_whatsapp_message_steps_table.php](/database/migrations/2026_07_02_000050_create_funnel_whatsapp_message_steps_table.php) — the multi-message step table.
- [database/migrations/2026_07_19_000001_add_event_series_id_to_funnel_whatsapp_messages.php](/database/migrations/2026_07_19_000001_add_event_series_id_to_funnel_whatsapp_messages.php) — **slot-scoped reminders**: nullable `event_series_id` (required for reminders, NULL for welcomes; legacy slot-less reminders are skipped by the scanner + flagged in the tab).
- [database/migrations/2026_08_07_200001_add_sms_fallback_to_funnel_whatsapp.php](/database/migrations/2026_08_07_200001_add_sms_fallback_to_funnel_whatsapp.php) — the **SMS fallback**: `sms_fallback` + `sms_fallback_body` on rules; the one-shot `sms_fallback_at` claim stamp + `sms_fallback_status` outcome on sends (see *SMS fallback* above; [FunnelSmsFallbackTest](/tests/Feature/Event/FunnelSmsFallbackTest.php)).
- [database/migrations/2026_08_13_100003_add_register_trigger_to_funnel_whatsapp.php](/database/migrations/2026_08_13_100003_add_register_trigger_to_funnel_whatsapp.php) — the **VSL ladder**: `watch_condition` + `booking_condition` on rules (nullable = no condition, which is what every session-timed rule is), and `funnel_whatsapp_sends.lead_funnel_id` + `UNIQUE(message, lead_funnel)` — the de-dupe the lead-keyed unique index cannot make, since a register-anchored send has a NULL `event_id` and MySQL treats NULLs as distinct.
- [database/migrations/2026_07_28_100003_add_group_audience_to_funnel_whatsapp.php](/database/migrations/2026_07_28_100003_add_group_audience_to_funnel_whatsapp.php) — the **GROUP audience**: `funnel_whatsapp_messages.audience` (`unsignedTinyInteger`, default LEAD) + `funnel_whatsapp_sends.group_key` (`string(64)` nullable **unique**, `"{rule}:{event}"`) — the one-shot claim the existing `UNIQUE(message, lead, event)` cannot make, since a group post has no lead and MySQL treats the NULL as distinct, so without it the five-minute scanner would re-post into the group on every tick. `lead_id` becomes **nullable** for the same reason: a group post is addressed to nobody (raw `ALTER` — doctrine/dbal isn't installed for `->change()`; `down()` deletes the lead-less rows before re-tightening).
- [tests/Feature/Event/FunnelWhatsappTest.php](/tests/Feature/Event/FunnelWhatsappTest.php) — welcome-on-every-registration, reminder due-window + de-dupe, the **catch-up bound** (a stale lead-time point is not fired late, the on-time point still fires, and a shorter rule on the same session is unaffected), provider-aware send, the **event + group token substitution** (`{{event_date}}` / `{{event_time}}` range / `{{event_location}}` / `{{whatsapp_group_link}}`), **multi-step order + `{{login_link}}`**, the **welcome next-registered-session** event tokens, and the **media step** (typed outbound + attachment).
- [tests/Feature/Event/FunnelWelcomeBackfillTest.php](/tests/Feature/Event/FunnelWelcomeBackfillTest.php) — the welcome back-fill: scope narrowing (funnel / slot / session), a slot welcome locked to its own slot, past sessions excluded unless asked (and cancelled ones always), one welcome per person across sessions, slot-welcome precedence respected while a sibling funnel welcome is not, idempotency on re-run, a FAILED row retried in place, the concurrency lock, and the endpoint's reminder / paused-rule refusals.
- [tests/Feature/Event/FunnelGroupPostTest.php](/tests/Feature/Event/FunnelGroupPostTest.php) — the GROUP audience + the after-the-session trigger: the gate (LID-only roster, announce-group member vs admin, departed member, Cloud, stale), the `group_key` one-shot (the scanner never re-posts), the after-event window (fires once after the start, never before, never long after), the per-lead after-event rule still reaching people **once Zoom has rewritten every registration to Attended / No-show**, and the **save-time pre-flight** (blocked while only an ordinary member, saves once promoted to admin, fails OPEN when the bridge cannot resolve the link, and the endpoint's verdict).
- [tests/Feature/Event/VslFunnelAutomationTest.php](/tests/Feature/Event/VslFunnelAutomationTest.php) — the **VSL ladder**: each watch bucket reaches exactly one person (and the rules that were not for them RECORD that rather than leaving the slot open), an unmeasurable percentage counts as under half, a second device never erases the longer watch, three consecutive scans send once, the never-retroactive guard, imported leads excluded, a paused funnel stops everything, both booking facts stand a nudge down while a bare consultation row does not, the send-time booking re-check, the VSL tokens resolving while the session tokens stay empty, and the two validation refusals (the trigger on a webinar funnel; a condition on a session-timed rule).
- [tests/Unit/Whatsapp/FunnelTemplateParamsTest.php](/tests/Unit/Whatsapp/FunnelTemplateParamsTest.php) — DB-free: Cloud template params keep their positional index (empty → filler, not dropped).

**Related handbooks**
- [Landing & Lead Capture](/docs/modules_handbook/main/landing-lead-capture/readMe.md) (the registration hook) · [Events · Funnels](/docs/modules_handbook/manage/events/funnels/readMe.md) (the funnel + sessions) · [WhatsApp](/docs/modules_handbook/manage/messages/whatsapp/readMe.md) + [Flow](/docs/modules_handbook/manage/messages/whatsapp/flow.md) (the send engine + why this isn't a flow).
