# Zoom (Manage)

**Portal:** Manage · **Routes:** `manage.integrations.zoom.*`, `manage.events.webinar.*`, `manage.leads.zoom-meetings.*`, `manage.leads.zoom-recordings.*`, `manage.zoom-meetings.*`, `manage.zoom.recordings.*`, `manage.people.admins.zoom-recordings.sync` (+ public `webhooks.zoom.handle`) · **Surfaced on:** **Integrations → Zoom** (credentials + account users), the **session detail** page (a "Webinar" tab), the **Lead** detail page (a "Zoom" tab), the **Admin** detail page (recordings), the **Calendar**, and the **Zoom Recordings** dashboard. · **Nav:** Channel → **Zoom** (one entry landing on `/manage/zoom/dashboard`, fronting the tab strip **Dashboard / Live / Action Items / Recordings / Polls / AI Agent / Knowledge Base / Settings** — Knowledge Base (`/manage/zoom/knowledge`) is the Advisory OS playbook manual as a designed reading page — Live (`/manage/zoom/live`, `manage.zoom.live.*`) is the RTMS live-copilot transcript view — Settings sits under `/manage/integrations/zoom`, the rest under `/manage/zoom`; rendered by [Components/SectionTabs.vue](/resources/js/Components/SectionTabs.vue), section `zoom`. Dashboard is the per-agent performance roll-up, Action Items the cross-meeting follow-up queue — the same performance layer shape as [Phone Call](/docs/modules_handbook/manage/call-history/readMe.md).)


> **AI readings on the lead page (2026-09-18):** Lead → Channel → Zoom → **Insights** reads every meeting transcript (a consultation reading) and the webinar history (interests + counted loyalty). See [Channel Insights · Zoom](/docs/modules_handbook/shared/channel-insights/zoom.md).
## What it does
A single **account-level Server-to-Server (S2S) OAuth** integration powers everything Zoom in the app — there is **no per-admin "connect your Zoom"** anymore. One Zoom Marketplace S2S app's credentials (Account ID, Client ID, Client Secret, a licensed webinar host, a webhook secret) are entered once on **Manage → Integrations → Zoom**, stored **encrypted in the database**, and used to act on behalf of any user on the account.

On top of that one integration the app provides three feature areas:
- **Webinars + attendance** (consumed by the [Events · Funnels](/docs/modules_handbook/manage/events/funnels/readMe.md) module) — create a Zoom **webinar per session**, auto-register enrolled leads, and track who actually **attended** (join/leave webhooks + the participant report) → each lead's `EventRegistration` is marked Attended / No-show with minutes. A shared **`WebinarAttendeeMatcher`** links each participant to a lead (registrant-id → email live; unique-name only at reconcile), reconcile then **mints a lead for every still-unmatched attendee who has an email** (`WebinarAttendeeLinker` → `LeadLinker`, so every participant ends up with a record), **captures walk-ins** (a matched non-registrant gets a `SOURCE_WALK_IN` registration) and **backfills** anonymous live segments, and the data surfaces on a per-session **Attendance dashboard** (live / final roster, no-show list + CSV/Excel export, unmatched participants) and on each lead's **"Zoom Webinar"** profile section.
- **1:1 meetings** — schedule, edit, cancel, start, and copy the invitation for Zoom meetings **against a lead** (Lead page) or **lead-less on the calendar**. Each meeting is created/hosted **as the acting admin's own Zoom user** (their email) — except that the calendar's scheduling form may name a **colleague on the Zoom account as host**, in which case the meeting is created under, and owned by, that colleague.
- **Cloud recordings + AI analysis** — sync each Zoom user's 1:1 recordings, cache the play/stream metadata, fetch the `.vtt` transcript, and run the shared **[`ConversationAnalyzer`](/docs/modules_handbook/shared/conversation-analysis/readMe.md)** (the SAME analyzer + schema as Phone Call and Showroom F2F — summary / sentiment / customer / sales performance / meeting report, with EN/中文). Surfaced on a unified **Zoom Recordings** dashboard + the Lead / Admin pages. The same dashboard also carries **webinar cloud recordings** (matched-host only) — see "Webinar recordings on the dashboard" below; a webinar recording is **never** AI-analyzed.

### Access model — "is a Zoom account user", never position
Who may use Zoom is decided by **one rule**: *is this admin's email a user on the connected Zoom account?* — `ZoomServerService::isAccountUser($email)` (a 15-minute-cached lookup of `GET /users`). This **replaced** the old position gate (`Caller`/`Closer`): there is no `zoom` route middleware, no `Admin::ZOOM_POSITIONS` / `requiresZoom()`, no `User::canUseZoom()`. The Admin **position** column still exists for sales workflow, but it no longer determines Zoom access. Frontend surfaces gate on the `is_zoom_user` prop; controllers re-check `isAccountUser` server-side before any write.

> All Zoom credentials live in **`Src\Zoom\ZoomServerCredential`** (`zoom_server_credentials`, a single encrypted row), resolved through **`ZoomCredentialProvider`** (DB-first, `.env` fallback). The legacy per-admin token store (`admin_zoom_credentials` / `Src\People\ZoomCredential` / `ZoomOAuthService` / the connect/disconnect OAuth flow) was **removed** and the table **dropped**.

## How it works

### S2S authentication (`ZoomServerService` + `ZoomCredentialProvider`)
`ZoomServerService` (`Src\Zoom\Services`) is the **only** place that talks to the Zoom REST API. It mints an **account access token** via the client-credentials grant (`grant_type=account_credentials&account_id=…`, Basic-auth with client id/secret), **caches** it (`zoom:s2s:access_token`, ~1 h minus a 120 s skew), and on a `401` forces one fresh token + retries the call exactly once. There is no per-user token and no refresh token. Account-level calls act on a specific user by putting their email/id in the path (`POST /users/{host}/webinars`, `POST /users/{host}/meetings`).

Credentials are never read from config directly by callers — they come from **`ZoomCredentialProvider`**: each value resolves **DB row first** (`ZoomServerCredential::current()`), then `config('services.zoom.*')` as a fallback. Accessing an encrypted column can throw `DecryptException` if `APP_KEY` rotated, so the provider catches it and degrades the value to "unconfigured" (null) — a rotated key yields a clean 401 / "not configured", never a 500 storm on the public webhook.

### Credentials storage + the Settings page
`ZoomServerCredential` is a **single row**. `client_secret` and `webhook_secret_token` use the `encrypted` cast + `$hidden` (never serialised); `account_id` / `client_id` / `webinar_host_email` are plain identifiers; `*_last4` columns back a masked "•••• 1234" display without decrypting; `verify_status` / `verify_message` / `last_verified_at` record the last soft-test.

**Manage → Integrations → Zoom** (`ZoomSettingsController`, page `Pages/Manage/Integrations/Zoom/Index.vue`) uses **`ShowTabs`**:
- **Connection tab** — the credentials form (secrets are **write-only**: a stored secret shows masked with a "Replace" affordance), a **Connection status** card, the **webhook endpoint** URL to copy. **Saving never blocks** — `update()` always persists, then runs a **soft-test** (a live token fetch + host-license check) and records the result; a failed test (wrong credential, or an app not yet activated at Zoom) shows as *status*, not a save error, surfacing Zoom's own reason (e.g. *"The app has been disabled by the developer"*).
- **Users tab** (only once configured) — lazily fetches `GET manage/integrations/zoom/users` (JSON, 15-min cached, with a Refresh that forces a fresh pull) and lists each account user's name / email / **License** (Licensed vs Basic) / role. This is the same source that drives the `is_zoom_user` indicator beside an admin's email on the **Admins** list.

### Webinars, attendance & engagement (Events-backing)
A Zoom-mode **session** (`Src\Event\Event`) gets at most one **`ZoomWebinar`** (`Src\Zoom\ZoomWebinar`, 1:1 on `event_id`), hosted by the licensed `webinar_host_email`. The single create-then-persist recipe lives in **`App\Actions\CreateSessionWebinarAction`** (resolve host → derive schedule from the session → `ZoomServerService::createWebinar` → persist via `ZoomWebinarRepository` → queue `SyncWebinarRegistrants`), shared by three callers. Every webinar is created with **`auto_recording => 'cloud'`** (`CreateSessionWebinarAction::settings()`, independent of the Zoom account default), so the session's **replay + engagement report are always captured**; editing a session never resets it (`sync()` sends the **schedule + agenda + title**, never `settings`). A registration-required webinar also gets its **Zoom-hosted registration form configured** right after creation — `applyRegistrationQuestions()` PATCHes `/webinars/{id}/registrants/questions` to keep **Last name** on and add a **required Phone**, because Zoom's default form asks only for first/last name + email, which is why "Zoom form" sign-ups used to reach the CRM with no phone at all (`WebinarRegistrantLinker` already reads `phone`, so it now flows straight through to the lead). It is best-effort — a failure is logged, never fatal, since the webinar already exists by then — and skipped for adopted (`SOURCE_DISCOVERED`) webinars. Webinars created **before** this shipped keep Zoom's default form until you run the one-off backfill **`php artisan zoom:apply-registration-questions`** (`--all` to include past ones; safe to re-run).
- **Manual** — the session detail **Webinar tab** → `WebinarsController@store` (synchronous; the admin waits). The webinar's **name follows the session title** (renaming the session re-syncs the topic to Zoom) and the schedule is derived from the session — unless the admin **renames the webinar itself** on the tab, which pins the name (see below).
- **Auto-on-add** — each funnel **slot** (`EventSeries`) has an **`auto_webinar`** toggle (default **on** for Zoom-mode slots). Adding a session is *slot + date*: **`SessionsController@store`** calls **`EventRepository::createFromSlot($slot, $date)`** (the slot → session field map, in its own transaction) and then, **after it commits**, `CreateSessionWebinarAction::dispatchForGenerated([$event])`: for each Zoom-mode session whose slot has `auto_webinar` on, it writes a **CREATING** placeholder row and queues the **`CreateSessionWebinar`** job (one per session → failure isolation + retry). It no-ops cleanly when Zoom is unconfigured, so adding a session never fails because Zoom is down. *(There is no round / cycle generation any more — `EventCycle`, `EventCycleRepository::generateNextRound()` and `CyclesController` were removed; `dispatchForGenerated()` keeps its name but is now called per added session.)*
- **Retry** — `WebinarsController@retry` re-queues a FAILED webinar.
- **Adopted ("Link Zoom webinar")** — the **reverse** of auto-create: a webinar scheduled in **Zoom's own portal** (with its own registrants) is bound to a funnel session from the funnel hub's Sessions tab (`SessionsController@linkable`/`@linkWebinar` → **`LinkSessionWebinarAction`** → `ZoomWebinarRepository::adoptForEvent`, row kept **`SOURCE_DISCOVERED`** + Upcoming), and **`ImportWebinarRegistrants`** pulls its Zoom registrants into the CRM through **`WebinarRegistrantLinker`** → the shared `LeadLinker` (match-or-mint by email; a form phone is `TRUST_UNVERIFIED` enrich-only; staff skipped), enrolling each with `source = SOURCE_ZOOM` and their **original** `zoom_registrant_id` + join link (so `SyncWebinarRegistrants` never re-registers them). The import is **recurring, not one-shot** — **`zoom:import-registrants`** (`ImportZoomRegistrants`, scheduled **every 5 minutes**) re-queues it for every session-backed, still-Upcoming/Live webinar with a `registration_url`, so people who sign up on Zoom *after* the link still reach the CRM in time for the funnel's WhatsApp reminders (the job is idempotent + `ShouldBeUnique` per webinar). An adopted webinar is **read-only at Zoom for anything DESTRUCTIVE or identifying**: `sync()`/`teardown()` skip the Zoom calls for `is_discovered` rows — editing/cancelling/deleting the session here never touches the real webinar (manage it in the Zoom portal); attendance, engagement and webhooks work exactly as for an app-created one. **Two writes are deliberate exceptions**, both because they finish something the adoption itself started: `SyncWebinarRegistrants` **adds our leads to it as registrants** (it has never had a source guard), and — since 2026-08-07 — [`PushWebinarEmailSettings`](/app/Jobs/Zoom/PushWebinarEmailSettings.php) turns **Zoom's own reminder + follow-up emails on** for it (dispatched by `LinkSessionWebinarAction` on every link, and by `zoom:push-email-settings`; the job's `includeAdopted` defaults to false so the rule stays the default). Both are additive and reversible; skipping the second would leave exactly the registrants we just pushed with no reminder. See [Funnel Automation](/docs/modules_handbook/manage/events/funnel-automation/readMe.md) → *Zoom's own emails*. Full flow in the [Funnels handbook](/docs/modules_handbook/manage/events/funnels/readMe.md).
- **Managed from its tab (rename / delete / open in Zoom)** — the Webinar tab exposes three direct actions (`WebinarsController@update` / `@destroy` / `@start`): **rename** the webinar (`PUT {id}/webinar` → `ZoomServerService::updateWebinar` topic-only; this sets **`zoom_webinars.topic_custom`**, which pins the name so a later session rename can't overwrite it — posting `follow_session` clears the flag and hands the name back to the session title; an *adopted* webinar can't be renamed here), **delete** it (`DELETE {id}/webinar` → `CreateSessionWebinarAction::teardown()`, at Zoom + locally; an adopted one is only **unbound**), and **Open in Zoom** (`GET {id}/webinar/start` → fetches a fresh host `start_url` and `redirect()->away()`, never stored — mirrors the meeting start action). Editing the session re-syncs the webinar's **schedule + agenda + title** at Zoom (`CreateSessionWebinarAction::sync`, via `EventsController@update`) — the title is skipped **only** for a `topic_custom` webinar, so a hand-picked name is never clobbered. **Cancelling** the session, switching it to **physical**, or **deleting** it tears the webinar down — `CreateSessionWebinarAction::teardown()` **deletes it at Zoom** (best-effort, 404-tolerant) and removes the local row, so no cancelled/deleted session ever leaves a live webinar on the account. **Deleting or cancelling a session notifies the registrants** — `teardown($event, notifyRegistrants: true)` passes `cancel_webinar_reminder=true` to Zoom's delete so it **emails every registrant that the webinar is cancelled** (their join link stops working with a heads-up, not silently); an in-place edit that merely retires the webinar (`reconcile()` → default `false`) stays silent so a benign change never spams.

The async lifecycle adds two `ZoomWebinar` statuses: **CREATING** (placeholder before/while the Zoom call runs — `zoom_webinar_id` blank) and **FAILED** (Zoom rejected it; the reason is on `sync_error`). `is_active` means a real webinar exists (Upcoming/Live/Ended) — not CREATING/FAILED/Cancelled. The Webinar tab is **state-aware**: CREATING → a "creating…" spinner (it polls until the webinar resolves); FAILED → a retryable error; **Upcoming** → join/registration links + passcode + registrant progress (X/Y leads registered) + copy affordances; **Live** → a 🔴 banner with the **current attendee count** (best-effort, from the Zoom Dashboard API — `getWebinarLiveParticipants`); **Ended** → the join links drop and it shows the **attendance summary** (Attended / No-show / avg minutes) + the **cloud-recording replay** once Zoom has processed it (`SyncWebinarRecording`). Idempotent: re-dispatching auto-create never duplicates a webinar (existing active webinars are skipped; the job only fulfils a row still in CREATING).

Attendance is automatic:
1. A registration enrols the lead — a **funnel landing** into all the funnel's **upcoming sessions**, a **slot landing** into the **one** session picked, an **admin add** into that session (`EventRegistration`, `source = SOURCE_LANDING` / `SOURCE_ADMIN`) — and on **every** path the shared **`SyncSessionWebinarRegistrantsAction`** queues a **`SyncWebinarRegistrants`** job for each enrolled session that has a live webinar, adding the lead as a Zoom **registrant** so Zoom emails them a unique join link + its own reminders; their `zoom_registrant_id` + `zoom_join_url` are stored on `event_registrations`. Creating a webinar runs the same sync to catch up everyone already enrolled, and the job is idempotent (only rows still missing a `zoom_registrant_id`).

   **The push is also swept on a schedule, not only dispatched.** Those dispatches are moments in time, so anything that went wrong in one of them used to be permanent — a queue worker that was down, a Zoom hiccup, a lead who enrolled while the webinar was still a CREATING placeholder, or a dispatch that simply never ran left the row stuck with a NULL `zoom_registrant_id` and **no retry anywhere**. That lead then never gets Zoom's own confirmation / reminder emails, has no personal join link (`{{event_location}}` silently falls back to the SHARED webinar link), and can only be matched in attendance by email or name instead of exactly. **`zoom:push-registrants`** ([PushZoomRegistrants](/app/Console/Commands/PushZoomRegistrants.php), scheduled **every 5 minutes**) is the mirror of `zoom:import-registrants`: same four scope filters (bound to a session, `registration_url` present, Upcoming/Live, started within 6h), opposite direction.

   **An ACCOUNT-level failure must never be charged to a row — and Zoom makes that easy to get wrong.** The retry ceiling (`MAX_ZOOM_SYNC_ATTEMPTS`) is right for a malformed email and catastrophic for an outage, because every pending registrant is charged on every five-minute sweep until they are all spent, and **nothing resets the counter** — so fixing the cause changes nothing and those people never get their join link. The trap is the status code: Zoom's OAuth token endpoint answers an invalid `client_id` / `client_secret` with **HTTP 400**, the same status `addWebinarRegistrant` uses for "this one email is malformed". This is not hypothetical — production ran on `Zoom 400: Could not authenticate with Zoom. Zoom said: Invalid client_id or client_secret` and charged it to every row. So the token step raises its own [`ZoomAuthException`](/src/Zoom/Exceptions/ZoomAuthException.php) (never classified by code), `SyncWebinarRegistrants` catches it FIRST and rethrows, and **`zoom:release-push-attempts`** ([ReleaseZoomPushAttempts](/app/Console/Commands/ReleaseZoomPushAttempts.php) → `EventRegistrationRepository::releaseZoomSyncAttempts`) hands the budget back to rows charged before that landed — matched on the recorded `zoom_sync_error`, so a genuinely refused registrant keeps its ceiling. Covered by `tests/Feature/Event/ZoomPushAuthFailureTest.php`.

   **Running the push by hand.** The scheduled form only *queues*, so on a server the outcome lives in the queue and "did it work?" has no visible answer. The command therefore takes `--sync` (push inline and print every row: `✓ email — Zoom has emailed their join link` / `✗ email — <the actual Zoom error>`), `--dry-run` (list who is pending, and why each is stuck, without calling Zoom), and `--event=<session uuid>` (one session — which also **bypasses the status/recency filters**, since an admin naming a session wants an answer about *that* one). Credentials are proved **first**, once: an account-level failure printed per registrant reads as N bad leads instead of one bad secret. **`zoom:push-registrants --sync` is normally the only command needed** — a row the sweep will still retry gets there on its own, and the two self-healing holds below clear themselves. `zoom:release-push-attempts` is the manual escape hatch for a row no automatic condition will free: it now reaches **zero-attempt** rows too (the ceiling-free skips sit at zero by design, which used to put them out of reach of the very command the blocked list points at). Everything the sweep will not push is excluded from `pending` and reported separately as **blocked** — tagged `[held]` or `[N-attempt ceiling]`, naming the recorded reason and printing the exact release command — without which they are simply invisible, the pending count reads like the whole backlog, and nobody learns why those people are stuck.

   **Zoom's registration FORM decides what the payload must contain, and it fails per-lead.** A host can mark any of Zoom's optional registration fields **required**; Zoom then rejects each push with `400 — The parameter is required: <field>.`, reported per registrant, so one setting on the webinar reads as bad data on every lead. Two are handled because we hold the data: **`last_name` is now ALWAYS sent** (a one-word name previously omitted the field entirely — common here, and it failed every such lead; the surname falls back to a visible `-` placeholder, leaving `first_name`, which is what Zoom's emails greet them by, untouched), and **`phone` is sent whenever we hold one**. Any *other* required field on that form (address, industry, job title…) cannot be satisfied from the CRM — un-tick it in Zoom → Webinar → Invitations → Registration → Questions.

   **Zoom rate-limits the push, and an ACCOUNT-wide 429 aborts the whole batch.** The job paces itself (`PACE_MICROSECONDS`, 0.2s between POSTs) so a dozen back-to-back adds do not trip the limit in the first place. When it trips anyway the job rethrows — right for the scheduled sweep, which just retries in five minutes, useless for `--sync` where an admin is watching — so the command **backs off and resumes** (`BACKOFF_SECONDS`, 15/30/60/120s). Each pass persists its successes immediately, so a retry only re-attempts what is still pending.

   **The PER-REGISTRANT daily cap is the one 429 that must not abort.** Zoom allows only 3 "Add registrant" calls per day *for the same email* and then 429s that email until 00:00 UTC — it says nothing about the account or about anyone else in the batch. Rethrowing it let ONE capped person jam every lead queued behind them, every sweep, all day. `isPerRegistrantDailyLimit()` tells the two apart on the phrase **`for the registrant`**, which only the per-email cap carries (matching merely on "daily" would catch the account-wide quota and skip the entire batch during an outage). It is recorded as `ZOOM_SKIP_DAILY_LIMIT`, charges nothing to the ceiling, and the sweep moves to the next lead.

   **A webinar whose session was soft-deleted pushes nobody.** `event_id` and the registrations both survive the delete, so an orphaned webinar would have Zoom email a join link for a session that no longer exists — guarded in both the command's scope (`whereHas('event')`) and the job itself (dispatched directly elsewhere).

   **The push sends `phone` whenever we hold one.** A webinar host can mark **Phone** a REQUIRED field on the Zoom registration form, and Zoom then rejects every push with `400 — The parameter is required: phone.` — reported *per registrant*, so it reads like a data problem with each lead instead of one setting on the webinar (production hit exactly this after the credential fix). Zoom ignores the field when the form does not ask for it, so sending it is never wrong. When the form requires it and the lead genuinely has **no** number, that is a LOCAL data gap, not a Zoom refusal: it records `ZOOM_SKIP_NO_PHONE` with `countsAgainstCeiling: false`, so typing the number in later still lets the push through instead of finding the lead already locked out. Detected by message text (`required: phone`) — Zoom has no distinct code for "your form asks for a field you did not send".

   **Copying one person's link by hand.** The Registrations tab's row action (shown **only when `is_registrant`**) calls `GET manage/events/registrations/{id}/join-link` (`RegistrationsController@joinLink`) and puts the result on the clipboard. It returns the **short `/zoom/{token}` link, not Zoom's registrant URL**, so a link an admin pastes into WhatsApp is byte-identical to the one the funnel's reminders send — and the token is **minted here if absent**, since it is created lazily and a copy is a first use. Non-registrants are refused with a 422 rather than handed a link: `/zoom/{token}` would resolve to the SHARED webinar URL for them, so offering it as "their personal link" would be false and would pollute the click tally that measures intent to attend. The route sits behind `manage-events` (minting a token is a write, and the link is a bearer credential). Covered by `tests/Feature/Event/RegistrationJoinLinkTest.php`.

   Because **every successful push makes Zoom email a real person**, the job is deliberately conservative: it is `ShouldBeUnique` per webinar (a stalled worker never stacks runs, and two overlapping runs cannot double-POST the same unlocked rows), capped at `BATCH` = 150 registrants per run (its 180s timeout equals the supervisor's, with no headroom), and it skips three classes outright — **walk-ins** (they were created BY attending, so a join link is noise), leads with **no email**, and leads whose email is a **`@notfound.com` placeholder** (`LeadLinker::isPlaceholderEmail`, which would hard-bounce and damage the Zoom account's sending reputation). Every failure and skip is recorded on the row itself — `zoom_sync_attempts` / `zoom_sync_attempted_at` / `zoom_sync_error` — and the Registrations tab's amber *"Not registered on Zoom"* chip says **why** (slate *Paused* while a hold stands, red once it has given up).

   **There are THREE stop conditions, not one, and they all live in `EventRegistration::pendingZoomPush()`** — the single definition of "who is pending", shared by the job and the command so the two cannot drift:
   - **the ceiling** — `zoom_sync_attempts` reaching `MAX_ZOOM_SYNC_ATTEMPTS` (5), for rows Zoom itself keeps refusing;
   - **a data-gap hold** — `no_email` / `placeholder_email` / `no_phone`, released when **the missing datum itself arrives**, not when the lead's row is next written. That distinction is the fix: watching `updated_at` re-opened a still-phoneless lead on every magic-link login (`markPhoneVerified()` saves unconditionally), spending one of Zoom's three daily calls to be refused for the same reason;
   - **a daily-limit hold** — `daily_limit`, released at the next 00:00 UTC (`zoomDailyLimitResetAt()`, returned in app time because that is how the column is stored).

   The last two are recorded with `countsAgainstCeiling: false` — the lead did nothing wrong — which is exactly why they need holds of their own. Production ran the loop for days without them: a phoneless lead was re-POSTed every five minutes, and because **Zoom counts rejected calls against the daily allowance too**, ~15 minutes of sweeping exhausted it and the rest of the day answered 429. `ZOOM_HELD_SKIPS` derives from `ZOOM_DATA_GAP_SKIPS` by spread, so a skip added to one cannot silently escape the other.

   **The join link a lead is actually sent is ours, not Zoom's.** A Zoom registrant `join_url` is **209–240 characters** of opaque token — it reads like spam in a WhatsApp bubble and the recipient cannot sanity-check it, and Zoom offers **no** shortened or vanity form of it (its short `join_url` / `registration_url` are session-wide, not per-lead). So `{{event_location}}` renders **`/zoom/{token}`** ([ZoomJoinController](/app/Http/Controllers/Main/ZoomJoinController.php), route `session.join`), backed by `event_registrations.join_token` — 16 random chars, minted lazily on first use, unguessable because it is a bearer credential. It resolves its destination **at click time** (personal `zoom_join_url` → the webinar's shared `join_url` → the session's static `zoom_link`), which means a reminder delivered *before* the push landed **upgrades itself** to the personal link with no new message. And because one token belongs to one registration, a click identifies **exactly** the lead who registered — no cookie, no matching, any device — tallied as `join_click_count` / `join_clicked_at` and written to their activity trail as `TYPE_SESSION_JOIN_CLICKED`. A cancelled session or an unknown token redirects to the site home rather than dead-ending. `zoom` is a reserved funnel slug so a funnel can never shadow the route.
2. During the webinar, `webinar.participant_joined` / `participant_left` webhooks log raw join/leave segments into **`zoom_webinar_attendances`**, matched to a lead via the shared **`WebinarAttendeeMatcher`** — on this live path it runs **only the deterministic tiers** (registrant-id, then case-insensitive email), never name matching (a mid-event false-positive must never attribute a stranger to a lead). The chosen tier is stored on each segment's `match_method`.
3. On `webinar.ended`, the webinar is marked Ended and **`ReconcileWebinarAttendance`** is queued (delayed ~5 min). It reads Zoom's authoritative **participant report** (falling back to the local segments) and runs the matcher with **`allowNameMatch: true`** (adding a *unique* normalized-name tier scoped to the funnel's leads — ambiguous → unmatched), then, in order: **creates walk-in `EventRegistration`s** (`source = SOURCE_WALK_IN`) for matched non-registrants who joined via a shared link; **backfills** `lead_id` / `event_registration_id` / `match_method` onto the anonymous live segments (`attributeSegments`, only ever adding attribution, never clobbering a good live match); and finally `finalizeAttendance()` marks every `EventRegistration` **Attended** (with minutes) or **No-show**. The per-session **Registrations tab** shows the rollup (its filter cards: Registered / Attended / No-show / Unknown people). Each row shows the lead's name / email / phone and flags only the **exceptions**: an amber **shield** beside an *unverified* email or phone (`email_verified` / `phone_verified` from `User::email_verified_at` + `UserProfile::phone_verified_at` — a landing registration creates the lead immediately, so unverified is normal, not an error), **No phone**, and — for a Zoom session that already has a webinar — **Not on Zoom** for anyone who is enrolled here but has no `zoom_registrant_id` yet (so Zoom hasn't emailed them a join link).

The matcher (**`Src\Zoom\Support\WebinarAttendeeMatcher`**, read-only — like `VttParser`) resolves in tiers, stopping at the first hit: **registrant-id** (`event_registrations.zoom_registrant_id`, event-scoped) → **email** (event registrants, then the funnel's leads) → **unique name** (reconcile-only; normalized lowercase/trim/honorific-stripped — the honorific list covers English + Malay titles incl. Puan/Encik/Tuan/Haji — matched in PHP, returned only when exactly one funnel lead matches). The name tier is **guarded against stranger misattribution**: it only runs on a name that is safe to identify a person by — **not a placeholder** (Guest / iPhone / Anonymous / Zoom user …) and **at least two tokens** (a lone surname like "Wong" collides far too easily — a stranger on a forwarded link would be attributed to a same-named lead; the unique-match guard only proves the name is unique among the funnel's leads, never that the participant IS that lead). `ZoomWebhookController::resolveAttendee()` calls it live-safe (name tier off); `ReconcileWebinarAttendance` calls it with name matching on. **Known limits** (heuristic, by design): a Zoom-join email ≠ the CRM email is a miss (we only match `users.email`); a shared family email resolves to its single lead; name-order / CJK-bilingual display names miss. These favour a miss over a wrong link.

**Two attendance surfaces** read this data (both direct-Eloquent reads, labels/colors resolved server-side from the model constant arrays per GUIDELINES §5):
- **Per-session Attendance dashboard** — an **Attendance tab** on the session detail page (`EventsController@show` adds an `attendance` prop via the shared **`LoadsWebinarAttendance`** trait; `AttendanceTab.vue`): the **live roster** (open segments + best-effort live count) while Live, the **final roster** (every attendee — lead link or raw name/email for unmatched, joined/left, minutes, `match_method`, source) once Ended, a **no-show list** (`status = No-show` **and** `source = SOURCE_LANDING` — landing registrants who didn't attend; walk-ins and admin-adds excluded) with a **CSV/Excel export** (`WebinarAttendanceController@exportNoShows` → `WebinarNoShowExport`, §14 `ExportsResource`), and an **unmatched-participants** section for manual follow-up. The tab self-refreshes via `router.reload({ only: ['attendance'] })`. The no-show list and the export read the **same** `LoadsWebinarAttendance::noShowRegistrations()` query, so they can never drift.
- **Lead "Zoom Webinar" section** — the Lead detail **Zoom tab** (`ZoomTab.vue`, since 2026-07-17 nested under the Lead's **Channel** tab at `?tab=channel&ctab=zoom`) is split into "Zoom Meeting" (existing) + a new **"Zoom Webinar"** section (`WebinarAttendancePanel.vue`): every webinar the lead joined — built from the lead's webinar-backed registrations **unioned** with walk-in segments (deduped so each webinar appears once) — showing session, funnel, date, minutes stayed, attendance status, and a walk-in label.

**Engagement (poll / quiz / Q&A)** is captured after the webinar ends, chained at the end of `ReconcileWebinarAttendance` (so it runs only once the attendance verdict is settled): **`SyncWebinarResponses`** pulls the webinar's **polls, quizzes and Q&A** from Zoom's report endpoints (`/report/webinars/{id}/polls` + `/qa`), merges in the per-question type/options from the poll **config** endpoint (`GET /webinars/{id}/polls`, which the report omits), matches each respondent to a lead with the same `WebinarAttendeeMatcher`, and persists via **`ZoomWebinarResponseRepository::syncFromReports`** — idempotent on deterministic SHA-1 keys so a re-sync fills late data without duplicating. Lead attribution on a re-sync is **upgrade-only** (`applyResponseAttribution`, the same match-tier discipline attendance uses): a re-sync whose matcher finds nobody — a **discovered** webinar has no funnel to match against — **never wipes** the `lead_id` a prior sync or the `zoom:link-poll-respondents` backfill attached, and never downgrades a stronger match; it only ever fills a blank or upgrades. (Before this, a Polls-page "Sync" of a discovered webinar reset every backfilled `lead_id` to null.) Questions land in **`zoom_webinar_questions`** (one row per question per webinar per `kind` — Poll / Quiz / Q&A; quiz detection is config-only, `poll_type === 3`, since the report carries no `correct` flag) and answers in **`zoom_webinar_responses`**. Each poll/quiz question links to a **canonical `zoom_polls`** row keyed on Zoom's **stable `poll_id`** (unique) via `zoom_poll_id`: the same **Poll-Library** poll reused across webinars resolves to **one** poll, and a rename in Zoom just refreshes the stored `title` on the next sync (`ZoomPollRepository::upsert` — title is a mutable attribute, never the identity; we never link polls by name). Surfaced on **two** read-only tabs, both built by `EventsController@show` (`engagement` prop via `buildEngagementProp`, null until the webinar has ended): the session's **Engagement tab** (`EngagementTab.vue`, nested Lead Engagement / Polls / Q&A / Chat sub-tabs) renders each question **by its Zoom type** — choice (bars), scale (avg + histogram), text (frequency groups, never bars), ranking / matching (tables) — with quiz correctness (derived at ingestion from the config's `right_answers`), a per-poll by-lead answer matrix, and a **"Re-sync from Zoom"** button (`WebinarAttendanceController@syncResponses`); and the lead's **Zoom Webinar** section shows that lead's own answers (`WebinarResponsesPanel.vue`). Survey results are deferred (no endpoint on the classic Webinar API); **reactions** are untracked (they were dropped from the model — no longer retrieved). **Chat** *is* captured — see below.

**Chat (manual `.txt` upload).** Chat is the **only manual-ingress path** in the whole Zoom module: Zoom exposes **no chat report endpoint** (attendance arrives by webhook, polls/Q&A via `SyncWebinarResponses`), so an admin exports the webinar chat as a plain `.txt` from the Zoom client and uploads it on the session's **Engagement → Chat** sub-tab (`POST manage.events.webinar.chat.store`; `UploadChatRequest` = `file|mimes:txt|max:10240`). **`ChatFileParser`** (pure/static — current *and* legacy header formats; tab-indented multi-line messages collapse into one row) parses the file, and **`ZoomWebinarChatRepository::syncFromUpload()`** matches each sender to a lead by **normalized name only** (the shared `WebinarAttendeeMatcher::normalizeName()` — honorific-stripping included) against a roster of **this webinar's** attendances-with-a-lead **∪** this event's registrations — never the funnel's or the global lead pool; a name resolving to more than one lead is left **unmatched**, never guessed. It then **replaces every prior row** for that webinar (the uploaded file is the source of truth — a truncated re-upload silently overwrites a fuller import; there is no versioning, audit trail, or merge). Matched senders feed each lead's engagement score (chat weight **1**; `score = 2×polls + 3×qa + 1×chats`) and surface in the Chat sub-tab's message log + by-sender view. Full details in the [Webinar chat](/docs/modules_handbook/manage/zoom/webinar-chat/readMe.md) handbook.

> **Two different jobs — matching vs creating — do NOT conflate the classes.** Zoom has two distinct identity concerns, and each has its own tool:
> - **Matching (attribution within a known event)** is **`WebinarAttendeeMatcher`** — read-only, funnel-scoped, *never creates*. Every ingestion path (attendance, poll/quiz/Q&A responses, chat) resolves a participant to an **already-existing** lead through it (1:1 meetings don't resolve at all — the operator picks the lead off the route). Its strongest signal is the Zoom `registrant_id`, which `LeadLinker` has no concept of, so it is genuinely a different tool — not a drifted copy of the gate.
> - **Creating a lead (global identity, mint if new)** is the shared **`Src\Lead\Services\LeadLinker`**, keyed on **email** (`TRUST_UNVERIFIED` — a webinar/poll email/phone is self-asserted). **Two** Zoom services create leads through it: (1) **`Src\Zoom\Services\WebinarAttendeeLinker`** — called inside `ReconcileWebinarAttendance` right after matching (so every attendee of a *future* webinar is minted automatically as it ends) **and** by the backfill command **`zoom:link-webinar-attendees`** (`LinkWebinarAttendees`, `--dry-run` / `--existing-only` / `--backdate`) for the *history* — old rows, and discovered-webinar attendees the event-scoped matcher could never match — swept account-wide by email; email-less participants are skipped (a display name is not an identity key); and (2) the backfill sweep **`Src\Zoom\Services\PollRespondentLinker`** (driven by `LinkZoomPollRespondents`), which does the same for *discovered*-webinar poll respondents account-wide.
>
> **`--backdate` (both backfill commands):** a history sweep can mint 1,400+ leads in one run, and dating them all "today" turns every new-leads chart into a one-day spike for people who actually arrived over months. With the flag, a lead the sweep **creates** is re-dated to that person's **earliest** recorded touch — first `joined_at` for attendees (app-local, carried over as-is), first `answered_at` for respondents (**stored UTC → converted to the app clock first**) — via `LeadRepository::backdateCreation()`, which moves the lead's funnel registrations with it and deliberately leaves `users.created_at` alone (the account really was opened today). Two hard rules, both from `backdateCreation`'s own contract: only a lead **this run created** is ever re-dated (an existing lead's date is real history), and the date only ever moves **backwards**. With `--dry-run` / `--existing-only` the flag is a warned no-op — those modes never create. Beware the name clash: `WebinarAttendeeMatcher` is an entirely separate class from `LeadLinker` — the matcher attributes to an existing lead, the linker creates one.

**Cross-webinar poll results (Zoom → Polls).** Because every question links to a canonical `zoom_polls` row, one poll's responses can be listed across **all** the webinars it ran in. **`Manage\Zoom\PollsController`** (`manage.zoom.polls.*`, nav **Zoom → Polls**) exposes this: the **index** lists every canonical poll (Poll / Type / total Responses, the count a correlated subquery + a **View** action); the **show** page (`/manage/zoom/polls/{uuid}`) shows a small header (Webinars / Responses / Unique leads) then a **paginated responses table — one row per answer** — across every webinar, each row carrying the matched **lead** (linked, or the raw participant name when unmatched), the question, the answer, quiz correctness (for a quiz), and the **webinar/session** it came from (linked to that session's Engagement tab). `zoom_polls` gained a **`uuid`** to route-bind this page. **⚠️ Grouping relies on the poll being reused from Zoom's account-level Poll Library so its `poll_id` is stable across webinars** — a poll re-created per webinar gets a fresh `poll_id` and would split into separate canonical rows (there is no title/content fallback merge today); validate against real data before relying on the combined view.

### Backfill — polls from webinars this app never created (Zoom → Polls → "Sync all from Zoom")
Everything above only reaches a webinar **this app created**: a `zoom_webinars` row could only be written by `CreateSessionWebinarAction`, which always binds one to an `Event`. Webinars run straight from **Zoom's own portal** were therefore structurally invisible — along with every poll answer they collected. Since those polls are in practice **lead-capture forms** (Name / Mobile / Email / intent / budget), that was the bulk of the account's poll data.

**The clock matters more than the feature.** Zoom's API serves reports for **six months only** — verified live, Zoom's verbatim refusal is *"The request can only be queried for a month that falls within the last six months."* The **1 year** in Zoom's KBs is **portal-only and unreachable from the API**. The window **rolls forward every month**, so anything not ingested in time is gone from the API permanently. `zoom_polls` / `zoom_webinar_responses` are the durable copy; Zoom is not an archive.

- **`ZoomWebinarDiscoveryService`** (`Src\Zoom\Services`) finds past webinar **occurrences** on the account and unions **two paths by occurrence uuid**, because neither is complete alone:
  - **Path A (report)** — `listUserMeetingsReport()` per host, **one calendar month per call** (Zoom's limit), filtered to `ZoomWebinar::ZOOM_WEBINAR_TYPES` (5/6/9). ⚠️ There is **no `GET /report/users/{id}/webinars`** (it 404s) — webinars come back from the **meetings** report. ⚠️ Zoom **omits anything with fewer than two unique participants**.
  - **Path B (list)** — `listUserWebinars()` then `listPastWebinarInstances()` per webinar. No participant floor, but ⚠️ the webinar `type` enum is only `["scheduled","upcoming"]` — there is **no `type=past`** (unlike meetings) — so it only reaches unexpired webinars.
  - Zoom's six-month refusal is treated as a **boundary, not an error**: it stops the walk and is reported as `wall` (the command prints it) rather than thrown.
  - A host with **no webinar licence** returns 400 *"Webinar plan is missing"*, mapped to an empty list — normal on a mixed account (live: **1 of 7 users** holds the licence), so every user can be looped without special-casing.
- **`ZoomWebinarRepository::upsertFromDiscovery()`** keys on the **occurrence uuid** and writes `event_id = null`, `source = SOURCE_DISCOVERED`, `status = Ended`. An occurrence the app **already owns** (its uuid stored by the `webinar.ended` webhook) resolves to that row and is returned **untouched** — it keeps its `event_id`, its `SOURCE_APP` marking and its session-bound lifecycle. Uniqueness is enforced **in PHP inside a transaction, deliberately not by a DB unique index**: soft-deleted rows keep occupying a MySQL unique index, so a torn-down webinar later rediscovered would collide on insert. `BackfillWebinarPolls` therefore carries **`WithoutOverlapping`** — that is what makes the lookup-then-insert safe, not decoration.
- **`App\Actions\BackfillWebinarPollsAction`** is the shared recipe (discover → run `SyncWebinarResponses` per webinar) so the command and the button can never drift. It **reuses the existing ingestion** rather than reimplementing it, so a backfilled webinar and a webhook-synced one produce byte-identical rows. It passes **`allowRetryOnEmpty: false`** — a historical report is already final, so empty means the webinar genuinely ran no polls and retrying would burn 5 attempts × `$backoff` per poll-less webinar.
- **Two callers, deliberately different modes:** `zoom:sync-polls {--months=} {--host=} {--queue}` runs **inline** (immediate counts, no queue worker needed — the `zoom:sync-recordings` pattern); the index's **"Sync all from Zoom"** button dispatches **`BackfillWebinarPolls`** (**queued** — discovery alone is ~50 API calls before a poll is read, far too slow for a request). The button is behind a `ConfirmModal` and its flash says results appear **over the following minutes**, rather than implying the list is already complete.
- **`SOURCE_DISCOVERED` webinars have no session**, so `webinar.event` is null and the Polls table shows the Zoom **topic** instead of a session link. The session-bound lifecycle (`sync` / `teardown` / `retry`) must keep ignoring them — this app never edits or deletes at Zoom a webinar it did not create.

> **⚠️ Always address a past occurrence by its UUID, never the numeric id.** Verified live: webinar `88287798631` has occurrences at 09:21 and 12:00, and `GET /report/webinars/88287798631/polls` returns **only the 12:00 one** — silently, with no error. `SyncWebinarResponses` prefers `zoom_webinar_uuid` and falls back to the numeric id, so a webinar whose `webinar.ended` webhook was missed can still sync the **wrong occurrence**; `listPastWebinarInstances()` can recover the correct uuid by `start_time` while the window is open. Encoding is **not** the trap here — `encodeWebinarId()`'s conditional double-encode is correct, and a UUID containing `/` works single-encoded (both encodings return the same echoed `uuid`).

> **`question_key` is content-derived, never position-derived.** `position` is only an encounter-order counter over Zoom's participant array, so keying on it meant a reordered report minted new keys and **inserted duplicate question rows instead of updating them** — the rule the file already states in `computeSubmissionSyntheticId()`, now applied to `question_key` too (poll fallback keys on `noid|prompt`; Q&A keys on its own `prompt|email|name` identity). A bulk backfill would have amplified this across every webinar at once, so it was fixed first. `ZoomPollBackfillTest::test_question_key_survives_a_reordered_resync` is the regression guard.

**Diagnostics:** `zoom:probe-polls` (read-only; every call a GET, writes nothing) answers the questions Zoom's docs contradict, against the real account — which hosts hold a webinar licence, where the retention wall actually falls, whether the poll report paginates (it does not), and what `email` carries for a non-registration webinar. Run it before diagnosing a backfill that found nothing.

### Past the wall — CSV report imports (`zoom:import-reports`)
Everything above still needs Zoom's **API**, which stops at the six-month wall described in the section above. Beyond it the data is not late, it is **gone**: measured on production, 109 webinars held attendance for only 5 (4.6%). Zoom's **web portal** still exports those as CSV, and `zoom:import-reports` is the only way in. Drop the exports into `storage/app/zoom-reports/attendee/` and `…/polls/`, run it once to preview and again with `--write`. Options: `--dir=` / `--timezone=` / `--skip-polls`.

- **It writes through the same repositories the API jobs use** (`ZoomWebinarAttendanceRepository::syncFromReport`, `ZoomWebinarResponseRepository::syncFromReports`, `ZoomWebinarRepository::firstOrCreateFromReport`), so a CSV-sourced row is indistinguishable from an API-sourced one and no second write path can drift. **`Src\Zoom\Support\ZoomReportCsv`** parses the export — a *sectioned* file, not a table: each of "Host Details" / "Panelist Details" / "Attendee Details" restates its own header with different columns, and a Poll Report's trailing column labels *are* the question prompts. **`Src\Zoom\Support\PollResponseKeys`** owns the three idempotency hashes so this command and `SyncWebinarResponses` cannot disagree about them.
- **Dry run by default**, and the preview is expected to equal what `--write` does. Only the ATTENDANCE pass can create a missing webinar row, so a poll export with no attendee counterpart is reported as skipped in *both* modes rather than promised in one. The two passes run attendance-then-polls for that reason; `test_polls_land_on_a_webinar_created_in_the_same_run` is the guard, because swapping them leaves every other test green.
- **Three timezone conventions meet here, and they are not the same.** `zoom_webinar_attendances.joined_at` is app-local; `zoom_webinars.start_time` and `zoom_webinar_responses.answered_at` are UTC. The export gives local wall-clock with **no offset**, so `ZoomReportCsv` re-emits every time with one. A wrong `--timezone` is now **refused up front** — it used to be swallowed into `null`, which made attendance drop every row while polls imported happily with `answered_at = NULL`, rows the skip guard below then made unrepairable by re-running.

> **⚠️ A Zoom webinar id is a SERIES, not a sitting.** One id can own several `zoom_webinars` rows — separate occurrences, or the same session discovered twice; production has 12 such ids, two of them (`87493107597`, `83902268637`) spanning different **days**. So the file list keys on the **filename** (only a literal `" (n)"` re-download suffix is collapsed) and `findWebinar()` picks the row whose start matches the export's, reporting the file as **ambiguous** rather than guessing. Keying either of those on the numeric id silently dropped a whole sitting's attendance, or filed it under the wrong one — with a console line that looked entirely normal.
>
> Three rules hold that line together, and each was a real defect before it existed. **(1)** An export whose meta/Overview block does not parse still gets an occurrence signal — the earliest join (attendance) or earliest answer (polls) — because *skipping* the check silently fell through to "whichever row already holds data". **(2)** With several rows and still no signal, the file is **ambiguous**, never a guess. **(3)** `claimRow()` lets one webinar row be claimed by **one occurrence per run**: the DB is *not* guaranteed to hold a row per sitting (every creation path keys `firstOrCreate` on the zoom id alone, and 85 of 97 ids own exactly one row), so without it two occurrence exports both resolved to that single row and the second sitting's attendees were merged onto the first sitting's webinar. A second file may share a row only if both starts are known and within the window; anything else is reported and skipped, and the run exits non-zero.

> **The poll skip guard counts POLL and QUIZ responses only.** `zoom_webinar_responses` holds Q&A (`kind = 3`) in the same table, and counting it treated a webinar whose audience merely typed questions as one whose polls were already ingested — 14 production webinars whose exports were discarded. The guard exists because the CSV carries no poll id, so its `question_key` is `noid|prompt` where the API's is `poll_id|prompt`; Q&A cannot collide with either (`qa|…`). The reverse direction — CSV first, API later — is handled instead by `ZoomWebinarResponseRepository::adoptIdlessTwin()`, which **re-keys** the id-less row onto the incoming key rather than letting a second copy of the question appear.
>
> **A CSV question can often recover its poll id anyway — from the questions the API already ingested.** `ImportZoomReports::promptPollMap()` treats a question's PROMPT as a fingerprint of its poll whenever the whole account uses that exact wording in only **one** poll (measured: 61 of 66 distinct prompts; the other 5 are generic form fields — "Name" alone lives in 16 different polls — and are excluded, as is poll-**title** matching, since three production polls are all named "Untitled poll"). A resolved prompt imports with the donor's `external_id`/`kind`, the poll's current title/type riding along in `options` (the repository refreshes `zoom_polls` via `updateOrCreate`, so a bare id would wash the title to NULL), and the **canonical** `sha1(poll_id|prompt)` key — landing under its `zoom_polls` row, visible on the cross-webinar Polls page, and mergeable by any later API sync. An unresolved or ambiguous prompt imports unlinked (`noid` key, `zoom_poll_id` NULL) exactly as before: it still shows on that webinar's own Poll & Q&A tab, just not on the cross-webinar page. The dry run reports the split as `poll questions linked` vs `poll questions new`. Adoption deliberately does **not** match on `kind` (the export carries no `poll_type`, so the importer writes `KIND_POLL` for everything and the API re-classifies to `KIND_QUIZ` — requiring equality blocked adoption for exactly the quizzes that needed it), and it **skips any twin whose key is in the same payload**: Zoom's report can omit `polling_id` on one detail row and supply it on another for the same prompt, and adopting there stranded the id-less key so the *next* sync re-created it with a second copy of every answer.

**Exit code is a real signal.** A file that could not be read, or whose occurrence could not be resolved, makes the run return `FAILURE` — a scheduled invocation must not read `exit 0` as "everything imported". A poll launched to silence is **not** such a file: Zoom writes the block's header with no rows under it (three of this account's 62 real exports do), so that is counted under `poll files with no answers` and leaves the exit code alone.

### 1:1 meetings (lead + calendar)
Every write is **Zoom-API-first, then persist** so half-state rows are impossible. The host is the **acting admin's own email** (`$request->user()->email`) — they must be a Zoom account user. The one exception is the Calendar store's optional `host_uuid`: a colleague from the actor's assignable staff pool who is also a Zoom account user, created under their email with `admin_id` set to their admin row (the S2S app acts for any user on the account, so no extra authentication is needed). Stored in the shared `zoom_meetings` table (`admin_id` = host; `lead_id` null = calendar-scheduled).
- **Lead** (`Leads\ZoomMeetingsController@store`) → `createMeeting($user->email, …)` then `ZoomMeetingRepository::create()`.
- **Manage** (`ZoomMeetingsController@update/cancel/destroy/start`) → `updateMeeting/deleteMeeting/getMeeting($id)` (account-level, no credential) + ownership (`admin_id`). These are the endpoints the **Calendar** detail modal uses for a lead meeting (`from_lead`), so a reschedule/cancel/delete made there still emails the lead. `destroy` is **lead-meetings only** (a lead-less row 404s — it belongs to the MANAGE_CALENDAR-gated calendar endpoint, and accepting it here would make that gate bypassable) and **refuses `STATUS_ENDED`** (that row backs the Recordings dashboard's recording / transcript / AI analysis). It soft-deletes the row after a best-effort Zoom delete when still scheduled/live, and emails the lead only when the meeting is **genuinely still ahead** — `wasScheduled && !isPast()`. The `isPast()` half is load-bearing: a "Past" meeting is still *stored* as UPCOMING, so a status-only guard would email the lead that a meeting which already came and went is cancelled. Since the calendar only offers Delete once a meeting is over, that notification never fires from the UI; it serves a direct API call.
- **Calendar** (`CalendarZoomMeetingsController`) → schedule / reschedule / cancel / delete / copy-invitation; an **optional lead** can be attached (resolves `lead_uuid`→`lead_id`, promoting it to a lead-flow meeting) — this is **no longer Closer-only**, any Zoom account user can. See the [Calendar](/docs/modules_handbook/manage/calendar/readMe.md) doc.
- Status (Upcoming → Live → Ended / Cancelled) is driven by the `meeting.started` / `meeting.ended` webhooks. A still-scheduled meeting whose window has elapsed **without** a confirmed end is surfaced as **"Past"** — a **display-only** status derived from `ZoomMeeting::isPast()` (stored `status` ∈ {Upcoming, Live} **and** `hasEndedByTime()`); it is **never stored** (the column stays Upcoming) and suppresses the Start / Copy-invitation / edit actions (`is_upcoming` / `can_manage` / `can_invite` go false). **"Ended" is reserved for a Zoom-confirmed end** (the `meeting.ended` webhook via `markEnded`, or a synced past recording). *(The earlier page-load `reconcileEnded()` fallback that force-wrote Ended by elapsed time was removed in favour of this derived label, so the calendar and Lead page now agree.)*

### Cloud recordings + AI analysis
`ZoomRecordingSyncService` coordinates the account-level recordings sync (no raw DB writes; all API via `ZoomServerService`, all persistence via the two repositories):
- `syncAll()` loops the admins whose **email is a Zoom account user** (was: Caller/Closer positions) and calls `syncAgent()` per admin by email; a per-agent `ZoomApiException` is caught + logged.
- `syncAgent()` → `listUserRecordings(email, from, to)` (30-day windows + pagination), resolves the lead by participant email (`getPastMeetingParticipants`), upserts the meeting (`createOrUpdateFromSync`, sticky `lead_id`, never downgrades `source='app'`) + recording files, fetches the `.vtt` transcript (`fetchRecordingFile` → `VttParser`), and dispatches **`AnalyzeZoomMeeting`** (Gemini, the `AiJob` lane) when analysis is requested.
- **Analysis is opt-in / explicit** — a backfill imports transcripts but analyses none automatically; admins queue capped batches. `queuePendingAnalysis()` (cap `ZOOM_ANALYZE_BATCH_CAP`) backs the `zoom:analyze-recordings` CLI command (analyze-only, by date/agent). `queuePendingProcessing()` backs the dashboard's "Transcribe + analyze filtered" batch action (`RecordingsController::processBatch()`, zoom-recordings-batch-pipeline.md) — a strict superset that also transcribes no-transcript rows (auto-chaining analyze), evaluated per-row against the branch matrix (webinar / in-flight / audioFile() / transcript content vs. `transcript_status` — see the service docblock for the two-signal reconciliation) and capped the same way.
- Inline playback proxies bytes through `ZoomRecordingStreamController` using the **account token** server-side (honours HTTP Range; `isZoomHost` anti-SSRF guard) — never exposing a token to the browser.
- The **Zoom Recordings** dashboard (`RecordingsController`, `/manage/zoom/recordings`) is open to **all admins** and follows the §14 DataTable pattern; per-lead/per-admin refresh buttons are gated on `isAccountUser`.

### Durable GCS archive (playback survives Zoom-side deletion)

**What it does.** Zoom cloud recordings live in Zoom's own storage; if a recording is deleted in the
Zoom app (a common space-freeing action), both `play_url` and `download_url` 404 and playback dies. A
**durable copy of the video + audio bytes** is written to the shared `MediaService` (private GCS) —
`zoom_recordings.media_id` points at it — so playback survives a Zoom-side deletion. Only **video +
audio** (`ZoomRecording::VIDEO_TYPES` + `AUDIO_TYPES`) are archived; transcript / chat / summary /
timeline stay Zoom-only (the transcript text is already durable in `zoom_meetings.transcript`). The
Zoom `play_url`/`download_url` are **kept untouched** as a secondary reference — nothing at Zoom is
ever deleted by this feature.

**How it works.**
1. **The job** — `App\Jobs\Zoom\ArchiveZoomRecording($recordingId, $force = false)` downloads the
   recording via `ZoomServerService::downloadRecordingToFile()` (streamed straight to a temp file,
   never buffered in memory) and re-hosts it via `MediaService::storeFromPath(null, $tmp, […])` —
   ownership is tracked **solely** via `zoom_recordings.media_id` (the same `media_id`-FK pattern as
   `CallRecording`/`F2fRecording`, never the `mediable` morph; `meta` still carries
   `zoom_recording_id`/`recording_type`/`zoom_meeting_id` for traceability). An atomic claim
   (`ZoomRecordingRepository::markArchiveProcessingIfEligible()`, a conditional `UPDATE` guarded on
   `archive_status <> ARCHIVE_PROCESSING`) stops two workers racing the same file. `--force`
   re-archives an already-archived row as a **safe swap**: store the new copy, verify its size against
   `file_size`, attach it (`attachMedia()`), and only THEN delete the superseded `Media` — never
   delete-then-replace, so a failed re-archive can never destroy a working copy. Runs on the
   **`redis-video`** queue lane by default (shared with AI-video renders — no new infra).
2. **Auto-dispatch on sync** — `ZoomRecordingSyncService::queueArchiving()` runs after every
   recording-cache upsert (both `syncAgent()` branches + `syncWebinar()` — archiving is type-based,
   never source-based, so webinar recordings are eligible too), gated OFF by default via
   `services.zoom.archive.enabled` (`ZOOM_ARCHIVE_RECORDINGS`) so a fresh deploy never silently starts
   uploading tens of GB. Skips rows without a `download_url`, already `ARCHIVE_PROCESSING`, and (by
   default) `ARCHIVE_FAILED` (`retry_failed_on_sync` opts back in).
3. **Backfill command** — `zoom:archive-recordings` (`--days` / `--agent` email-or-id / `--meeting`
   uuid / `--type` video\|audio\|all / `--limit` / `--force` / `--dry-run` / `--sync`) runs
   **regardless** of `archive.enabled` (that flag only gates the sync hook) — see its own `--help` for
   the prod batching runbook (a large burst of Zoom downloads gets rate-limited; run in `--limit`
   batches, letting the job's `$tries`/`$backoff` absorb transient `429`s).
4. **Playback prefers the archive** — `ZoomRecordingStreamController::show()` serves from GCS
   (`media_id` set) with hand-rolled byte-range handling (`200`/`206`/`416`; `readStream()` + `fseek()`
   by default; an opt-in `stream_via_signed_url` config flag issues a ranged fetch against a signed URL
   instead, for heavy video-seeking), falling back to the existing Zoom S2S proxy when unarchived.
   `ZoomRecording::isStreamable()` is true when EITHER `media_id` OR `download_url` is present, so an
   archived-then-Zoom-deleted recording stays playable.

**Config** (`config('services.zoom.archive.*')`, `config/services.php` under the existing `zoom` key):
`enabled` (`ZOOM_ARCHIVE_RECORDINGS`, default `false`) · `collection` (`zoom-recording`, no env) ·
`queue_connection` (`ZOOM_ARCHIVE_QUEUE_CONNECTION`, default `redis-video`) · `download_timeout`
(`ZOOM_ARCHIVE_DOWNLOAD_TIMEOUT`, default `900`) · `retry_failed_on_sync`
(`ZOOM_ARCHIVE_RETRY_FAILED_ON_SYNC`, default `false`) · `stream_via_signed_url`
(`ZOOM_ARCHIVE_STREAM_VIA_SIGNED_URL`, default `false`) · `backfill_max_processes` (`3`, no env —
documented cap for an optional dedicated `redis-media` supervisor upgrade).

> **Enabling in prod is a deliberate step, not a deploy side-effect:** set
> `ZOOM_ARCHIVE_RECORDINGS=true` → `php artisan config:cache` only after GCS and the queue lane are
> confirmed ready. See `docs/DEPLOY.md`'s "Zoom recording media archiving" section for the full runbook.

### Webinar recordings on the dashboard

**What it does.** A Zoom **webinar's** cloud recording is surfaced on the same **Zoom Recordings** dashboard as 1:1 meetings — playable detail page, streaming, filters, sort, pagination — but treated differently: it is **never** AI-analyzed, its detail page swaps the AI/Sales/Report tabs for an **inline Poll & Q&A** tab, and it never leaks into any meeting-only surface. Represented as a **`ZoomMeeting` row** (rather than a second dashboard/detail stack) so the existing list/detail/player/stream machinery "just works" for both.

**Discriminator — `source`, not `zoom_webinar_id`.** The row is discriminated by **`source = ZoomMeeting::SOURCE_WEBINAR`** (`'webinar'`; `SOURCES['webinar']` renders a distinct violet "Webinar" chip). `ZoomMeeting::isWebinar()` is `source === SOURCE_WEBINAR`. Keying on `source` (not the `zoom_webinar_id` column) is deliberate: a webinar synced from the account recordings list is *standalone* — it may have no Event — yet must still be recognised and hidden everywhere. The `zoom_webinar_id` column (nullable, **unique** integer FK to `zoom_webinars.id` — Eloquent-level only, no schema FK per GUIDELINES §7) remains only as the **FK + idempotency key** linking each dashboard row to its backing `ZoomWebinar`; it is not the discriminator.

**⚠️ Two different `zoom_webinar_id` columns, same name.** `zoom_meetings.zoom_webinar_id` is the **local integer** `zoom_webinars.id` (the FK/idempotency key). `zoom_webinars.zoom_webinar_id` is the **Zoom API string id**. Never cross them — the sync paths read both (`$webinar->id` → the FK column; `$webinar->zoom_webinar_id` / a recording group's `id` → the Zoom string) and keep them straight.

**Default-exclude global scope.** `ZoomMeeting::booted()` registers a `'meetingsOnly'` global scope (`where('source', '!=', SOURCE_WEBINAR)`), so **every pre-existing `zoom_meetings` consumer stays meetings-only with zero code changes** — the calendar month grid, the Lead "Zoom" tab, `AdminZoomRecordingsController`, `Leads\ZoomRecordingsController`, `queuePendingAnalysis`, `AdminsController`'s people-profile recordings feed (via `whereHas('meeting', … withTrashed())`, which inherits this scope), and any other `Admin::whereHas('zoomMeetings')` surface never see a webinar row unless they explicitly opt in via `ZoomMeeting::scopeWithWebinars()` (`->withWebinars()`, which just calls `withoutGlobalScope('meetingsOnly')`). The **only** call sites that opt in are: the webinar sync path (`ZoomMeetingRepository::createOrUpdateFromWebinarSync()`) and `RecordingsController` — `index` (main list query + `agentCounts`/`categoryCounts`/`typeCounts` + the `Admin::whereHas('zoomMeetings', …)` options query), `show`, `syncEngagement`, `analyze`, `transcribe`, `translateAnalysis`, `link` (the last four resolve the row only to reject it — see below). `aiStatusCounts` is a deliberate **exception** that stays meetings-only (a webinar has no AI status). `ZoomRecordingStreamController` needs no opt-in at all — it queries the unscoped `ZoomRecording` model directly and never derefs `->meeting`.

**Two ingestion paths, one canonical row.** A webinar recording reaches the dashboard two ways, and they **converge on a single row** (no duplicate, whatever the order):
- **Event-linked** — `ZoomRecordingSyncService::syncWebinar(ZoomWebinar)`, triggered from the same two places as `SyncWebinarRecording` (the `recording.completed` webhook and the Events page's lazy fallback) via the sibling job `SyncWebinarRecordingToMeeting`, plus the one-time backfill command `zoom:sync-webinar-recordings` (candidates: ended webinars with `RECORDING_AVAILABLE`).
- **Standalone** — `ZoomRecordingSyncService::syncAgent()` ingesting **Zoom recording types 5/6/9** (`WEBINAR_ZOOM_TYPES`) returned by the account recordings list (`GET /users/{id}/recordings`) alongside meetings, during the normal `zoom:sync-recordings` / "Sync" run.

Both paths resolve the backing `ZoomWebinar` **first** by the Zoom **string** id via `ZoomWebinarRepository::firstOrCreateEventless()` (reusing an Event-linked webinar with that id when one exists, else creating an **eventless** one), then upsert the dashboard row through the **shared** `ZoomMeetingRepository::createOrUpdateFromWebinarSync()`, keyed on that webinar's **local** id (`zoom_webinar_id`). Because `firstOrCreateEventless()` guarantees one `ZoomWebinar` per Zoom id, the two triggers always hit the same FK-keyed row; the **nullable-unique** `zoom_webinar_id` index is the DB-level backstop, and a benign concurrent-insert race is caught (SQLSTATE 23000) and re-queried rather than surfaced as a failure. **Invariant: at most one `ZoomMeeting` row per webinar, regardless of ingestion order, and re-running either path is idempotent** (regression-tested in `SyncAgentWebinarSyncTest`).

An **unmatched host is skipped entirely** — `syncWebinar()` maps `host_email` → `Admin` by login email (the same account-user matching `syncAll()` uses) and writes no `ZoomMeeting`/`zoom_recordings` row when it does not map (v1 requires a matched host, so `admin_id` is NOT NULL on both tables with no null-safety sweep; the backfill command reports skipped emails, never silent). Recording files are fetched via the same `getMeetingRecordings()` endpoint (keyed by webinar id) and cached via the same `ZoomRecordingRepository`.

**Eventless `ZoomWebinar` (`event_id` nullable).** So a standalone webinar (no Events session) can still reuse the poll/Q&A ingestion (`SyncWebinarResponses`) and the shared engagement builder — all keyed on a `ZoomWebinar` — `zoom_webinars.event_id` is **nullable**. `firstOrCreateEventless()` mints an eventless row (status Ended, recording Available); Event-linked webinars are unchanged. `SyncWebinarResponses::dispatch()` runs once, on first creation of the backing webinar.

**Never AI-analyzed (belt-and-suspenders).** Neither ingestion path dispatches `AnalyzeZoomMeeting`/`TranscribeZoomMeeting` for a webinar: the standalone branch of `syncAgent()` does no lead-match and no transcript fetch, and `syncWebinar()`'s transcript helper `maybeStoreWebinarTranscript()` is a separate method with no dispatch calls at all (not a flag-gated branch of the meeting path, so there is no flag to accidentally flip). `queuePendingAnalysis()` runs under the default meetings-only scope, so a webinar row is never selectable even though it may carry a stored transcript. `RecordingsController@analyze`/`@transcribe`/`@translateAnalysis`/`@link` additionally guard `$meeting->isWebinar()` with a friendly `flash()->error()` + redirect (`@link` because a webinar has many attendees, not one lead — the Show page also hides the link/unlink UI for a webinar row). The Index dashboard shows a neutral "—" AI-status chip (no Bot icon) for a webinar row instead of "Pending", and `aiStatusCounts` never includes one.

**Never human-reviewed either — and the guard here is genuinely two-layered, not belt-and-suspenders for show.** The write endpoint refuses a webinar "for free": `ZoomMeeting`'s `meetingsOnly` global scope hides webinar rows from the plain `where('uuid', …)->firstOrFail()` lookup `RecordingReviewsController::store()` uses, so it 404s before `RecordingReviewGate::allows()` is ever consulted (the gate class itself IS reached first, for `modelFor()`'s whitelist lookup — what never runs is the three-clause check). But `RecordingsController::resolveDetail()` **deliberately** loads `->withWebinars()` (so a webinar row can be shown on this dashboard at all) — which means `RecordingReviewGate::allows()`'s own webinar clause is the ONLY thing keeping a webinar's `can_review` false on that path. Removing that clause as "redundant" would make a webinar's detail payload lie: `can_review: true` for an action the write endpoint still refuses.

**Detail page — inline Poll & Q&A tab (via the shared `WebinarEngagementBuilder`).** `RecordingsController@show` resolves the backing `ZoomWebinar` (the relation when Event-linked, else the eventless row) and attaches the **full** engagement payload built by `Src\Zoom\Services\WebinarEngagementBuilder::build($webinar, $syncUrl, $chatUploadUrl)` — the SAME builder + payload the Events page uses (see *Reference usage* below). `RecordingDetail.vue`'s tab set becomes `Overview / Transcript / Poll & Q&A` for a webinar (never `AI analysis / Customer / Sales performance / Meeting report`) — a purely additive branch that never affects a call/F2f/meeting recording, which never sets `is_webinar`. The Poll & Q&A tab (`Tabs/PollQnaTab.vue`) renders the inline `Components/WebinarEngagement/WebinarEngagementPanel.vue` (Lead Engagement / Polls / Q&A / Chat sub-tabs) and a **"Sync poll & Q&A from Zoom"** button → `RecordingsController@syncEngagement` (`POST manage.zoom.recordings.sync-engagement`), which authorizes lead-or-owner, guards `isWebinar()`, ensures an eventless `ZoomWebinar` backs the recording, and runs `SyncWebinarResponses` inline. **(This reverses plan decision D5 — full inline engagement, not summary + link-out.)**

**Two TABS, not a three-way filter (supersedes D7's `all` option).** The dashboard is split into **Meetings | Webinars** tabs backed by the same `type` param (`RecordingsQueryRequest::filterType()` → `where('source', …)` on `SOURCE_WEBINAR`) with `typeCounts`, `LeadVisibility`-scoped. There is **no `all`** any more: `'webinar'` keeps webinar rows, and `'meeting'` — **or an absent value** — keeps meeting rows only.

> **⚠️ The absent case is load-bearing, and it lives in `RecordingsQueryRequest::apply()`, not in `filterType()`.** The frontend omits a filter param equal to its dimension default, so a bare visit sends **no** `type` at all — and `QueryRequest::apply()` dispatches `filter{X}` **only for params present in the request** (`$request->only($filterable)`, then `collect(...)->filter()`). Without the `merge(['type' => 'meeting'])` default that `apply()` injects, `filterType()` would never be called, the query would fall through **unconstrained**, and webinar rows would leak into the Meetings tab — whose Agent / Lead / AI columns and transcribe+analyze batch never apply to a webinar. `filterType()`'s else-branch reads as if it handles the absent case on its own; it does not, because it is never reached. **Do not "simplify" the `merge()` away.**

Visibility for a webinar row (`lead_id` always null, `admin_id` = host) is **unchanged**: it passes through the existing `LeadVisibility::applyToZoomMeetings` lead-or-owner rule — visible to all-access users (managers/super-admins) **and** the owning host admin; a scoped non-host admin does not see it. This is a deliberate, explicit decision (not a `LeadVisibility` code change) — broadening it to "every admin sees every webinar recording" would need an explicit `orWhere('source', SOURCE_WEBINAR)` added on purpose, not assumed.

### Standalone-webinar attendance (the Webinars tab's Attendance column + tab)

**What it does.** A webinar surfaced by the Recordings dashboard is synced from the **account recordings** endpoint and has **no Event** — so until this landed it had zero attendance rows and the Webinars tab could not answer "how many people actually came?". A separate, deliberately smaller pass now records **participation only**, so the tab shows an honest attendee count and the detail modal gains an **Attendance** tab.

**Why not just run `ReconcileWebinarAttendance`?** Because that pass does two things that are impossible or descoped for an eventless webinar: it creates **walk-in `EventRegistration`s** (impossible against a null `event_id`) and it **mints a Lead for every attendee** (descoped — `plan_doc/webinar-lead-autocreate.md`). `ReconcileWebinarAttendance::handle()` therefore **returns early when `event_id` is null**, logging and dispatching `SyncWebinarAttendance` instead — the correct pass for a standalone webinar. The `webinar.ended` webhook dispatches reconcile unconditionally, so that guard is what keeps an eventless webinar off the lead-minting path.

**`SyncWebinarAttendance`** (the standalone counterpart to reconcile) reads Zoom's past-webinar participant report (`GET /report/webinars/{id}/participants`) and upserts segments via `ZoomWebinarAttendanceRepository::syncFromReport()`. It writes **participation only** — `lead_id` / `event_registration_id` / `match_method` are never set here, so an Event-linked webinar that later runs the full reconcile keeps and upgrades its own attribution. `tries = 4`, `backoff = 120 s` (the report lands a few minutes after a webinar closes).

#### "Sync now" pulls engagement too — `ZoomRecordingSyncService::pullWebinarEngagement()`

One click on **Manage → Zoom → Recordings → Sync now** now leaves nothing for the per-webinar buttons to finish: for every webinar the pass ingests it also runs `SyncWebinarAttendance` **and** `SyncWebinarResponses`, so attendance, polls, quizzes and Q&A all land in the same click. The hook sits beside `queueArchiving()` and is called from `syncAgent()`'s webinar branch immediately after the recording files are cached, so the side effect stays in lockstep with the persistence step.

> **⚠️ It replaced a pull that had never actually run.** The old code was `if ($webinar->wasRecentlyCreated) { SyncWebinarResponses::dispatch(...); SyncWebinarAttendance::dispatch(..., false); }` — invisible **twice over**. The gate meant it fired only on the pass that first minted the `zoom_webinars` row, so a webinar ingested before attendance existed could never get any and re-clicking Sync now could not repair it; and `::dispatch` put both jobs on the **queue**, whose worker runs in the developer's WSL2 while the button is clicked on Windows, so they were written to Redis and never consumed. The admin got a green "Sync complete" and an empty Attendance tab.

- **Inline, never queued** — `app()->call([$job, 'handle'])`, the same way both modal buttons already run these jobs (`RecordingsController@syncAttendance` / `@syncEngagement`) and the same promise `sync()`'s docblock makes: *"Runs synchronously — Redis/queue is not required locally."* A queued fan-out would reproduce the original bug in a new costume. `release()` on a job that was never queued is a no-op, which is what makes this safe.
- **Scope is the sync window, never the table.** Four Zoom calls per webinar (poll config, poll report, Q&A report, participants report) against a ~60 s request ceiling; a normal week is 0–3 webinars. `services.zoom.engagement.batch_cap` (default 5) bounds a first run after a long gap, and anything over it is counted as **deferred** and reported as "click Sync now again to continue". A full-history sweep already has homes built for it — Manage → Zoom → Polls "Sync all from Zoom" (queued), `zoom:sync-polls`, `zoom:backfill-webinar-attendance`, and `zoom:import-reports` past the wall.
- **Nothing may throw out of the hook.** `syncAll()` catches `ZoomApiException` **per agent** and abandons that agent's remaining recordings, so one rate-limited webinar would silently cost the admin the plain meeting recordings queued behind it. Each report family is caught separately (`\Throwable`, not `ZoomApiException`) — a missing Q&A scope must not cost the attendance pull — and a 429/5xx sets `$engagementHalted` so the rest of the run short-circuits to *deferred* at zero API cost while the recording sync carries on.
- **Age comes from the RECORDING, not from `zoom_webinars.start_time`.** For a recurring series that column holds occurrence #1's start and can be a year older than the recording in hand, so the six-month wall check would skip a webinar recorded yesterday. Anything genuinely past the wall is counted as `engagement_skipped_old` and the flash points at the CSV import instead of spending four doomed calls.
- **The occurrence uuid is refreshed first, and only for an eventless row.** `firstOrCreateEventless` keys on the numeric id alone, so a recurring webinar's row keeps occurrence #1's uuid forever — and `SyncWebinarResponses` prefers that uuid, so an un-refreshed row re-reads the first sitting on every pass. Flipping an **Event-linked** row's uuid would change which occurrence the Events page re-syncs, hence the `event_id === null` guard.
- **Counters:** `engagement_pulled` / `engagement_deferred` / `engagement_skipped_old` / `engagement_failed`, each surfaced in the flash. ⚠️ A new counter must be added in **four** places — both initialiser arrays in `syncAgent()`, `syncAll()`'s `$totals`, and the hard-coded fold key list in `syncAll()` — miss the last and the value is silently dropped.
- **Config:** `services.zoom.engagement.on_sync` (default **ON** — unlike archive's fail-safe OFF, since having this happen IS the feature; the flag is a kill switch for the day Zoom rate-limits), `batch_cap`, `lookback_months`.

> **⚠️ The cap is a CLI OPTION, not an env override — because production runs `config:cache`.** `zoom:sync-recordings --engagement-cap=` threads a value through `syncAll()` / `syncAgent()` to the hook; `0` means no limit. Do **not** reach for `ZOOM_ENGAGEMENT_BATCH_CAP=0 php artisan …`: with a cached config `env()` is never consulted at runtime (`scripts/deploy-update.sh` runs `config:cache` on every deploy), so the prefix is silently ignored, the default cap of 5 wins, and a 6-month backfill quietly stops after five webinars per agent while reporting the rest as `deferred`. The one-off backfill is therefore:
>
> ```bash
> php artisan zoom:sync-recordings --days=185 --engagement-cap=0
> ```
- **`syncWebinar()` (the Event-linked path) deliberately does NOT do this.** An Event-linked webinar already gets `ReconcileWebinarAttendance` → `SyncWebinarResponses` from the `webinar.ended` webhook plus the Events page's own re-sync, so an extra four inline calls inside a queued job would buy no coverage.
- **The per-webinar buttons stay.** They are the only route to a webinar outside the sync window — which is most of the list — and the retry path the failure flash points at.

#### `WebinarParticipantIdentity` — the single definition of "who is this participant"

Zoom describes the same person three ways and **only one is stable**, so every consumer resolves identity through `Src\Zoom\Support\WebinarParticipantIdentity` rather than reaching for a raw field. Measured against real data (92 report rows = 62 real people):

| Field | What it really is | Why it fails as a key |
|---|---|---|
| `id` | the Zoom **account** user id | populated only for signed-in accounts — **3 non-empty of 92**; keying on it drops ~97% of attendees |
| `user_id` | a per-**session** id | a re-joiner gets a new one every join — **92 distinct ids for 62 people** |
| `email` | stable per person | present on all 92 rows, resolving to the correct 62 — **this is the identity** |

Keys carry a source prefix (`PREFIX_EMAIL` / `PREFIX_ZOOM` / `PREFIX_SESSION`) so provenance is explicit and an email can never collide with a raw Zoom id. This class exists because the two consumers previously **disagreed**: the standalone path keyed on `id` and dropped 89 of 92 attendees, while the reconcile pass keyed on `id` too and collapsed every anonymous attendee into one empty-string bucket — summing all their durations onto a single registration and marking real attendees **No-show**.

> **⚠️ `participant_uuid` holds two keyspaces — converge, never assume.** The `webinar.participant_joined` webhook writes Zoom's **real per-session UUID** into `zoom_webinar_attendances.participant_uuid`; the report path would naturally write a `WebinarParticipantIdentity` key (`email:…`). Same column, same unique index, **incompatible vocabularies** — and because `firstOrCreateEventless()` reuses an Event-linked webinar when one exists, both the Attendance tab's sync button and the backfill command reach webinars that already hold webhook rows. `syncFromReport()` therefore resolves each report row through the private **`reportRowKey()`**, which looks for an existing segment by `(webinar, email, joined_at)` and writes under **that row's** `participant_uuid`; only a participant the webhook never saw gets a new row under the identity key. Without this, every attendee on an Event-linked webinar doubles — inflating the tab's count and, once reconcile attributes both copies, **doubling `attended_minutes`** on the registration. `segmentSecondsByRegistration()` carries a matching defensive guard (inner `MAX` per person/join-instant, outer `SUM`) so a genuine re-join still accumulates while a duplicate cannot. Regression-guarded in `SyncWebinarAttendanceTest` + `ReconcileWebinarAttendanceTest`.

> **⚠️ Timestamps are stored in the APP timezone, not UTC.** Both writers of `joined_at` / `left_at` convert with `->setTimezone(config('app.timezone'))` — `ZoomWebhookController::parseTime()` and `ZoomWebinarAttendanceRepository::parseReportTime()`. Eloquent stores a Carbon in **its own** timezone without converting, so a report time left as UTC lands 8 hours early on the same column the webhook fills correctly. The `->comment('Stored in UTC')` on the (already-committed, therefore frozen per GUIDELINES §7) `2026_06_23_000002_create_zoom_webinar_attendances_table` migration is **wrong** — the convention is documented on `ZoomWebinarAttendance`'s `@property` block instead.

**On-demand sync + the Attendance tab.** `RecordingsController@syncAttendance` (`POST manage.zoom.recordings.sync-attendance`) authorizes lead-or-owner, guards `isWebinar()`, ensures an eventless `ZoomWebinar` backs the recording — linking it through **`ZoomMeetingRepository::linkWebinar()`** (transactional, idempotent, returns the refreshed model; never a bare `$meeting->save()` in the controller) — and runs `SyncWebinarAttendance` inline. The roster is attached by `ZoomRecordingDetailBuilder::buildAttendance()`, which emits `lead_uuid` + `lead_name` (never the internal sequential `lead_id`) with `->with('lead.user.profile')` to keep it one query and the §4.8 `full_name ?: email` fallback.

**Shared roster component.** `Components/WebinarEngagement/AttendanceRoster.vue` is the one piece of attendance UI genuinely common to both surfaces — the Events session Attendance tab's "Full attendance roster" and the Recordings detail Attendance tab — with optional columns **prop-gated** (`showLeft` / `showMatch` / `showSource`) so neither caller sends fields it does not have, and a duration that accepts either a preformatted `duration_label` (Recordings) or `duration_minutes` (Events), so **no backend payload had to change** to introduce it. Deliberately scoped to the roster alone: the Events tab's other three tables (live roster, unmatched participants, no-shows) all depend on `EventRegistration`s and stay there — a standalone webinar has no registrations, so it cannot have no-shows or "unmatched vs registered".

**Backfill — `zoom:backfill-webinar-attendance`** (`--dry-run` / `--relink-only`) repairs two gaps on historical rows: (1) **relink** — rows ingested before the converged upsert still have `zoom_meetings.zoom_webinar_id = NULL` and cannot reach their `ZoomWebinar`, matched on the Zoom string id and written through `linkWebinar()`; (2) **attendance** — queue `SyncWebinarAttendance` for eventless webinars that only ever had `SyncWebinarResponses` dispatched. Passes `allowRetryOnEmpty: false` (a historical report is final — empty means nobody attended, not "not ready"), deliberately never runs reconcile, and is idempotent on re-run.

#### Reference usage — `WebinarEngagementBuilder`
The engagement payload (summary rows + Poll/Quiz/Q&A/Chat) is owned by **one** shared service, `Src\Zoom\Services\WebinarEngagementBuilder`, so the Events page and the Recordings detail page stay on identical shapes:

```php
// Inject it (constructor), then build for a ZoomWebinar. Returns null for a
// missing/not-yet-ended webinar; a has_data:false shape when ended but unsynced.
$engagement = $this->engagementBuilder->build(
    $webinar,                 // ZoomWebinar|null
    $resyncUrl,               // this surface's "re-sync responses" POST endpoint
    $chatUploadUrl,           // this surface's chat-upload endpoint, or null if none
);
```

- **Events** (`EventsController::buildEngagementProp`) passes the event-scoped `.../webinar/responses/sync` + `.../webinar/chat` URLs — output is byte-identical to before the extraction.
- **Recordings** (`RecordingsController@show`) passes the `sync-engagement` route and (only when Event-linked) the event's chat-upload URL; a standalone webinar passes `null` for chat upload.
The Vue side is shared too: `Components/WebinarEngagement/WebinarEngagementPanel.vue` (+ `EngagementSummary` / `PollResults` / `QaResults` / `ChatResults` / `PollQuestionCard`) is consumed by both the Events `EngagementTab.vue` and the Recordings `PollQnaTab.vue`.

### AI transcription fallback
When a synced meeting has **no Zoom-generated transcript** (`transcriptFile()` returns null — typically because the host never enabled "Create audio transcript" in Zoom), the sync pipeline falls back to **AI ASR** via the shared `Src\Transcription` service — an ordered driver chain, **Gemini (primary) → Deepgram (fallback)** per `config('ai.transcription.drivers')`. The result is shown in the UI with an amber provenance badge — it can never masquerade as Zoom's official transcript.

**Driver-accurate provenance.** The stored `transcript_source` records the driver that **actually** produced the transcript: `'gemini'` (badge **"AI (Gemini)"**) or `'deepgram'` (badge **"AI (Deepgram)"**). Each driver stamps its own slug on `TranscriptionResult::provider`, which propagates unchanged up through `FallbackTranscriber` (returns the winning driver's result) and `LoggingTranscriber`; `TranscribeZoomMeeting` maps that slug to the matching `TRANSCRIPT_SOURCE_*` constant. A null/unknown provider falls back to the generic `'deepgram'` label — still honestly "AI, not Zoom". (The `ai_requests` log, one row per driver attempt via `LoggingTranscriber`, remains the source of truth for per-attempt cost.)

**When it triggers.** `ZoomRecordingSyncService::maybeFetchTranscript()` evaluates in order:
1. `transcript` already present → skip (idempotent; never overwrites).
2. Zoom VTT file exists → fetch + parse (`VttParser`) + save as `transcript_source = 'zoom'` (the preferred source), then dispatch `AnalyzeZoomMeeting` when `$analyze = true`.
3. No VTT but `audioFile()` returns an `audio_only` recording with a `download_url` **and** `$analyze = true` → dispatch `TranscribeZoomMeeting` and increment `transcriptions_queued`.

`$analyze = true` is passed by: **"Sync now"** on the dashboard (`sync()` calls `syncAll(…, analyze: true)`); `zoom:sync-recordings --analyze` (CLI); and `zoom:sync-recordings --transcribe-only` (queues transcription for existing meetings without a full Zoom API re-sync, respecting `--days` and `--agent`).

An admin can also trigger on demand via the **"Transcribe"** or **"Retry transcription"** button on the Show page → `POST …/{uuid}/transcribe` (route `manage.zoom.recordings.transcribe`) → `RecordingsController::transcribe()`.

**`TranscribeZoomMeeting` job (`App\Jobs\Zoom`).** Plain `ShouldQueue` on the `redis-transcription` / `transcription` lane (invariant: job `$timeout` 600 s < supervisor 650 s < `retry_after` 700 s — the default lane's 180 s timeout would kill a long pass, e.g. Deepgram's ~240 s dual-pass or a Gemini File-API upload on big audio). Steps:
1. Skip if the transcript is already populated (idempotent, no `force` path).
2. Guard: `audioFile()` must exist with a `download_url`.
3. Fail soft if `TranscriptionService::isConfigured()` is false (no driver configured — neither Gemini nor Deepgram key) — leave row as-is, not a failure.
4. **Atomic claim** — `ZoomMeetingRepository::markTranscriptProcessingIfEligible($meeting)`: inside a `lockForUpdate` transaction, sets `transcript_status = TRANSCRIPT_PROCESSING` only if `transcript` is **empty** and status is not already PROCESSING. **Empty content — not the status flag — is the overwrite guard**, so a FAILED row (still empty) *and* a DONE row whose content is empty (a failed-save lie — the §3.3 row-11 recovery amendment, 2026-07-19) are both re-eligible; a DONE row with real content is rejected by the empty-content test, never re-transcribed. Returns `false` if another worker already claimed the row; job aborts immediately.
5. **Stream** the audio to a temp file via `ZoomServerService::downloadRecordingToFile($audioFile->download_url)` (S2S account token; `isZoomHost` anti-SSRF guard) — never buffered in memory, so a long meeting cannot OOM. `ini_set('memory_limit', '512M')` covers the provider's parsed JSON response.
6. Transcribe: `$transcription->transcribeFile($audioPath, ['languages' => ['zh','en'], 'diarize' => false, 'subject' => $meeting])`, with the temp file `@unlink`ed in a `finally`. `diarize = false` is a locked v1 decision — **both** shared drivers default to `true`, so this override is mandatory.
7. On failure: `setTranscriptStatus(TRANSCRIPT_FAILED)`, log, and **throw** so `$tries = 3` / `$backoff = 60 s` retry transient provider errors.
8. On success: map `$result->provider` (the winning driver's slug) to the matching `TRANSCRIPT_SOURCE_*` constant, then `saveTranscript($meeting, $result->text, $source, $result->raw)` (sets `transcript_source`, `transcript_status = DONE`, `transcript_fetched_at`) → dispatch `AnalyzeZoomMeeting` (Gemini, `redis-ai` lane).

**Concurrency guards (both required).** `WithoutOverlapping("zoom-transcribe:{meetingId}")` middleware (with `releaseAfter=60` s and `expireAfter=$timeout+60`) is the first-line defence. The atomic `markTranscriptProcessingIfEligible()` is the hard cross-worker guarantee. Together they prevent duplicate transcription billing regardless of how many dispatches arrive.

**`failed()` hook.** If the job exhausts retries or is killed by a worker timeout, `failed(\Throwable)` re-resolves `ZoomMeetingRepository` via `app()` (constructor DI is unavailable in `failed()`), reloads the meeting by `$this->meetingId`, and sets `transcript_status = TRANSCRIPT_FAILED` — a crashed job never strands the row in PROCESSING.

**New columns on `zoom_meetings`** (migration `2026_06_28_000001_add_transcript_fallback_to_zoom_meetings`):
- `transcript_source` (`string(10)` nullable, indexed): `'zoom'` | `'gemini'` | `'deepgram'`; null until a transcript is saved. The VTT path explicitly passes `'zoom'`; the AI fallback job passes the slug of the driver that actually ran (`'gemini'` or `'deepgram'`).
- `transcript_status` (`unsignedInteger`, default `TRANSCRIPT_NONE`, indexed): lifecycle for the AI transcription job — `NONE(0)` / `PROCESSING(1)` / `DONE(2)` / `FAILED(3)`. Each constant has a `TRANSCRIPT_STATUSES` metadata entry (`name` + `color`). Distinct from `ai_status` (which tracks Gemini analysis).

Constants on `ZoomMeeting`: `TRANSCRIPT_NONE` / `TRANSCRIPT_PROCESSING` / `TRANSCRIPT_DONE` / `TRANSCRIPT_FAILED` (+ `TRANSCRIPT_STATUSES`); `TRANSCRIPT_SOURCE_ZOOM` / `TRANSCRIPT_SOURCE_GEMINI` / `TRANSCRIPT_SOURCE_DEEPGRAM` (+ `TRANSCRIPT_SOURCES` with name and optional color). `toDetailArray()` resolves label and color strings so Vue never hardcodes them (GUIDELINES §5). `audioFile(): ?ZoomRecording` returns the `audio_only` row that has a `download_url`.

**Transcript tab UI states.** `TranscriptTab.vue` renders four states driven by `recording.transcript` + `recording.transcript_status`:
1. **Transcript present** — text + copy button. When the transcript came from AI (`transcript_source` is any non-null value other than `'zoom'` — e.g. `'gemini'` / `'deepgram'`), an **"AI (…)"** amber provenance badge appears (label/color from `transcript_source_label` / `transcript_source_color` in `toDetailArray()` — never hardcoded in Vue).
2. **No transcript, status = `processing`** — spinner + in-progress message.
3. **No transcript, status = `failed`** — error state + **"Retry transcription"** button.
4. **No transcript, other status** — empty state + **"Transcribe"** button.

Both action buttons POST to `manage.zoom.recordings.transcribe`; the controller guards duplicate dispatches; the atomic claim enforces idempotency.

> **Shared service cross-link:** `Src\Transcription` (contract, driver, queue-lane invariant, reference usage pattern) is documented in [Transcription](/docs/modules_handbook/shared/transcription/readMe.md).

### Live Copilot (RTMS) — how a meeting reaches `/manage/zoom/live`

**There is no Start button, by design (2026-09-07).** The page is passive: Zoom announces a meeting's **Realtime Media Stream**, this site listens, the meeting appears. The chain, and where each step can fail:

1. **Zoom side (account admin, once):** the *Propertylab Zoom Sales Copilot* app — a **user-managed General App**, because Zoom allows RTMS scopes on no other kind — is added to the account's users (Marketplace → the app → *add app for users and groups*; per-user "Add" is the fallback). In the Zoom web portal, *Share realtime meeting content with apps* is on and the app is selected under *Auto-start apps that access shared realtime meeting content* (account, group or user level — scope it to the advisors' group to keep internal meetings out). With *Require host approval* on, **the HOST gets an Approve / Deny modal** as the meeting starts; participants only see Zoom's own disclosure ("The content of this meeting is being shared with one or more apps") — nobody but the host decides. Deny = no stream, no cost. **A user without the app hosts a meeting → nothing happens and nothing is logged**; that is the first thing to check when "it didn't appear".
2. **`meeting.rtms_started` → `POST /webhooks/zoom-rtms`** ([ZoomRtmsWebhookController](/app/Http/Controllers/Webhooks/ZoomRtmsWebhookController.php)) — signed with the **RTMS app's own** secret (`ZOOM_RTMS_WEBHOOK_SECRET_TOKEN`, env-only), never the S2S one. The controller creates the `zoom_consultations` row (**the meeting is on the Live table from this moment**), stamps `stream_started_at`, and:
   - **launches** the sidecar via [ZoomRtmsLauncher](/src/Zoom/Services/ZoomRtmsLauncher.php) (`nohup /opt/node22/bin/node tools/zoom-rtms/src/join.js`, one process per stream) → `stream_status = LAUNCHED`; or
   - **refuses** and records why on `stream_note` (`ZOOM_RTMS_ENABLED` off, sidecar path / Node 22 missing, a missing credential named, no stream details) → `NOT_LAUNCHED`. A **redelivered** announcement whose stream already has its consumer is NOT a refusal — the first launch stands. A request carrying `X-Peta-Forwarded` (a copy relayed from another site, see *Webhooks* below) is **never launched**: `NOT_LAUNCHED` + "Forwarded copy — the copilot runs on the site Zoom talks to."
   - queues **[IdentifyLiveMeeting](/app/Jobs/Zoom/IdentifyLiveMeeting.php)**: one S2S read (`GET /meetings/{uuid}`, single-encoded unless the uuid starts with `/` or contains `//`) fills `topic` / `host_email` / `host_name` (from the cached account-user list) / `zoom_meeting_id`, and links `lead_id` when the calendar (`zoom_meetings`) or an Appointment Engine booking (`appointments.zoom_meeting_id` → AE lead → CRM lead) created the meeting. Best-effort: without the `meeting:read` scope the row simply stays `Meeting e+7+mv…`.
3. **The sidecar** connects to Zoom's media servers, transcribes (`ZOOM_RTMS_STT=gemini`, one `gemini-3.5-transcribe-live` session per speaker), and POSTs batches to `bot/zoom-rtms/transcripts` (bearer `ZOOM_RTMS_RELAY_TOKEN`) → `zoom_live_transcripts`. Each stored batch debounces one `AnalyzeLiveConsultation` pass (20 s, capped at `ZOOM_RTMS_COPILOT_MAX_RUNS`) when `ZOOM_RTMS_COPILOT` is on.
4. **`meeting.rtms_stopped`** stamps `STOPPED` + Zoom's `stop_reason` (`ZoomConsultation::STOP_REASONS` — 3 host left, 6 meeting ended, **7 the host did not allow the copilot**, **11 nobody connected: the sidecar did not start on the receiving site**, 18 bad RTMS credentials…) and queues `FinalizeLiveConsultation` (+4 min, re-queued while the room still talks). A stop for an **older** stream id of the same meeting is ignored (a restart gets a new stream id). `meeting.rtms_interrupted` → `INTERRUPTED` (Zoom does not resume).

**Who has added the app — and the Connect step.** Zoom has no API that lists who added a user-managed app, and a host without it simply never appears here, so the app's **OAuth redirect points at this site**: `GET /zoom-copilot/connected` ([ZoomCopilotConnectController](/app/Http/Controllers/Main/ZoomCopilotConnectController.php), public — the invite link is shared on WhatsApp) exchanges the code with the RTMS app's own credentials ([ZoomCopilotAppService](/src/Zoom/Services/ZoomCopilotAppService.php)), asks `GET /users/me` who authorized, and records them in `zoom_copilot_installs` ([ZoomCopilotInstall](/src/Zoom/ZoomCopilotInstall.php); the token is dropped straight after — nothing per-user is ever called again). `app_deauthorized` on the RTMS webhook closes the row. Two surfaces read it: **Integrations → Zoom → Users** gains a *Copilot* column (Added / Not added) and the shareable **invite link** (Zoom's authorize URL, no `state`); and **`/manage/zoom/live` gates an admin who IS a Zoom account user but has NOT added the app** — they see only the *Connect my Zoom* step (the authorize URL carrying an encrypted `state` naming the admin, so the install is attributed) until they have. Admins who are not Zoom users are never gated. Zoom-side prerequisite: the app's **OAuth Redirect URL / Allow List** must carry `https://<host>/zoom-copilot/connected` for every site that serves the link. The gate is fail-open when the account user list cannot be fetched.

**One presenter, two surfaces (2026-09-07).** Everything meeting-shaped the copilot shows — the transcript lines, the console's consultation state, the post-call debrief with its conversation mechanics, the stream status — comes from [`ZoomConsultationPresenter`](/src/Zoom/Services/ZoomConsultationPresenter.php) (`lines()` / `consultation()` / `report()` / `status()` / `forRecording()`). The Live page reads it, and so does the **Recordings detail modal**: `ZoomRecordingDetailBuilder::build()` sets `recording.copilot = forRecording($meeting)` — the consultation joined on the Zoom **instance uuid** (`zoom_meetings.zoom_meeting_uuid` = `zoom_consultations.meeting_uuid`; never the numeric id, which a recurring/PMI meeting shares across instances), null for a webinar or a meeting the copilot never heard. When set, [`RecordingDetail.vue`](/resources/js/Components/RecordingDetail/RecordingDetail.vue) adds two tabs — **Live Copilot** ([`Tabs/CopilotTab.vue`](/resources/js/Components/RecordingDetail/Tabs/CopilotTab.vue): the live transcript + the stage console, read-only) and **Copilot Report** ([`Tabs/CopilotReportTab.vue`](/resources/js/Components/RecordingDetail/Tabs/CopilotReportTab.vue): the debrief, with the scorecard total as the tab count) — and relabels the recording's own tab **"Recording transcript"**, because one meeting now has two transcripts (Zoom's cloud VTT vs the copilot's live diarized one) and two scores (the post-hoc analysis vs the playbook scorecard); both stay, labelled. Call / F2F recordings never carry `copilot`, so their tab set is untouched (`RecordingDetail.copilot.test.js`). The timeline, console and debrief are the shared components [`Components/Zoom/CopilotTimeline.vue`](/resources/js/Components/Zoom/CopilotTimeline.vue), [`CopilotConsole.vue`](/resources/js/Components/Zoom/CopilotConsole.vue) and [`CopilotReportBody.vue`](/resources/js/Components/Zoom/CopilotReportBody.vue); the Live page composes them with what is live (the 2s poll, auto-open, the stage jump), the modal renders them read-only. Each Live row links to its recording (`recording_url`, `?detail={uuid}&tab=copilot`) once the sync has imported it. **A copilot meeting that was not cloud-recorded, or not yet synced, exists only on the Live page** — the Recordings list is `whereHas('recordings')` by design.

**The Live table** ([ZoomLiveController@meetings](/app/Http/Controllers/Manage/Zoom/ZoomLiveController.php)) is sourced from `zoom_consultations` (14 days, `coach-%` role-plays excluded), with the transcript aggregate joined in, and computes one **Status** per row — `waiting` (launched, no words yet: "waiting for the host to allow it"), `listening` (a line within 30 s), `quiet`, `not_launched` (+ the note), `interrupted`, `stopped` (+ the stop reason), `completed` (score). A `LAUNCHED` row with no activity for 4 h reads as `stopped` ("no end event reached this site") so a missed webhook can never keep a row live. `live` = waiting / listening / quiet, and is what the page **auto-opens** on the table view (unless the advisor stepped back out of that meeting). The 2 s `feed` poll refreshes all of it.

**Only the site Zoom talks to listens.** Point BOTH Marketplace Event Subscriptions at production and let production relay copies to dev (Forwarding tab); do not run `ZOOM_RTMS_ENABLED=true` on two sites for one stream. Prod needs the whole `ZOOM_RTMS_*` set, Node 22 at `ZOOM_RTMS_NODE`, `npm ci` in `tools/zoom-rtms`, a Gemini key, and `ZOOM_RTMS_APP_URL` = its own public URL (the sidecar POSTs back to it). Cost reference: one 92-minute consultation = 218 live passes + 1 post-call ≈ US$2.84 on `gemini-3.6-flash`, plus per-speaker live STT and ~0.02 Zoom credit/min.

### Client Avatars — mined, then scored, on their own (2026-09-17)

**Zoom → AI Copilot → Client Avatars** (`/manage/zoom/avatars`) is the library of real customers: every recorded conversation — a Zoom meeting's transcript, or a phone call of at least `ZoomClientAvatar::MIN_CALL_SECONDS` (150 s) — mined into one `zoom_client_avatars` row. `avatar` holds a role-play persona in the AI Coach's own schema (DISC, budget, fears, hidden objections); `scenario` holds the conversation's story (verbatim objections, outcome, difficulty, advisor review) and, once scored, the row's two KPIs: **`scenario.journey`** — 顾问分, the advisor's 8-stage / 13-item audit ([MineJourneyScore](/app/Jobs/Zoom/MineJourneyScore.php), re-reads the transcript) — and **`scenario.crs`** — 客户分, the customer's five readiness dimensions + three risk flags ([ScoreCustomerReadiness](/app/Jobs/Zoom/ScoreCustomerReadiness.php), reads the dossier only). Every pass runs Claude Fable 5 on the `AiJob` lane.

**The chain, and what starts it:**
1. **A meeting ends and its transcript is saved** — by the recordings sync's Zoom VTT fetch (`ZoomRecordingSyncService::maybeFetchTranscript()`) or by the AI transcription fallback (`TranscribeZoomMeeting`). Both call `MineClientAvatar::queueForNewTranscript($meeting)`, which queues [MineClientAvatar](/app/Jobs/Zoom/MineClientAvatar.php) **only when `services.zoom.auto_mine_avatars` (`ZOOM_AUTO_MINE_AVATARS`) is on — default OFF**, because every meeting mined is a paid pass. That hook is the only automatic entry: a meeting with no cloud recording never gets a transcript, and one Zoom did not transcribe only gets one when the AI fallback runs (`ZOOM_AUTO_ANALYZE_NEW`, or the Recordings page's Transcribe). Phone calls are never mined on their own — only by the page's mine button or `calls:mine-avatars`.
2. **Mining** first decides whether it was a client conversation at all: no → `STATUS_SKIPPED`, nothing more is spent; yes → `STATUS_READY`.
3. **A READY avatar queues both scores straight away** — `MineJourneyScore` + `ScoreCustomerReadiness`, from `MineClientAvatar` and `MineCallAvatar` alike. This step is **not** behind the switch: whatever mined the avatar (the switch, the mine button, `zoom:mine-avatars`, `calls:mine-avatars`), its two KPI columns fill in by themselves instead of sitting at 评分中…. A forced re-mine replaces `scenario` wholesale, scores included, and is re-scored the same way.
4. The two scoring passes finish in any order and each merges ONE key into the same `scenario` JSON, so `ZoomClientAvatarRepository::mergeScenario()` locks the row (`lockForUpdate`) and merges onto what is stored — a plain read-merge-write lets the later pass overwrite the earlier one's key, and that score stays empty for good.

**Cost:** a client consultation = three Fable passes (mining and the journey audit each read the transcript, capped at 120k chars; the CRS reads the dossier); an internal meeting = one (mined, then skipped).

**Catch-up** (manual; each command only fills gaps): `zoom:mine-avatars` (fetches missing VTT files first) · `calls:mine-avatars` · `avatars:journey-score` · `avatars:crs` · `avatars:score` (difficulty + location for rows mined before the prompt emitted them). Nothing re-queues a step on a schedule, so a queue failure between steps — or an avatar mined before this chain existed — keeps its gap until `avatars:journey-score` / `avatars:crs` are run.

### Webhooks (two endpoints, one per Marketplace app)
`POST webhooks/zoom` (public, CSRF-exempt via `webhooks/*`) is authenticated by Zoom's **signature** on every request — `v0=HMAC-SHA256(secretToken, "v0:{ts}:{rawBody}")` with a 300 s timestamp-replay guard — plus the `endpoint.url_validation` CRC challenge. The secret comes from `ZoomCredentialProvider::webhookSecretToken()`. (`POST webhooks/zoom-rtms` is the **Live Copilot app's** subscription — same contract, its own secret — see the section above.) **Both endpoints relay every verified event** to the Zoom integration's forward targets (Integrations → Zoom → **Forwarding**; targets hang off the `ZoomServerCredential` row and are matched **by endpoint path**, so add one target per endpoint you want copied — the [Webhook Forwarding](/docs/modules_handbook/shared/webhook-forwarding/readMe.md) handbook has the rules; *Send test* signs a synthetic event with the right app's secret so the other site proves it holds the same one). The CRC challenge is never relayed. The thin controller verifies → dispatches to idempotent handlers, all writing through repositories (never the DB or Zoom API directly):
- `meeting.started` / `meeting.ended` → `markLive()` / `markEnded()`.
- `recording.completed` → a **meeting** upserts its files (shared repository method); a **webinar** queues **`SyncWebinarRecording`** to store its replay link.
- `webinar.started` / `webinar.ended` → `markLive()` / `markEnded()` (+ queue `ReconcileWebinarAttendance`). `webinar.ended` also **marks the bound session Completed** (`EventRepository::complete`, only when still Scheduled) — so an over-running or early-ending webinar drives the session's derived **`live_status`** to Past exactly when Zoom says it ended, not by the scheduled clock (see the [funnels handbook](/docs/modules_handbook/manage/events/funnels/readMe.md) *Live status*).
- `webinar.participant_joined` / `participant_left` → log/close an attendance segment.

(The legacy `app_deauthorized` handler was removed with the per-admin connection.)

> **⚠️ The webinar lifecycle has NO polling fallback — and its failure is silent.** `markLive()` has exactly one caller (`webinar.started`) and `ReconcileWebinarAttendance` exactly one dispatcher (`webinar.ended`); nothing in the scheduler re-derives either. So when Zoom cannot reach the endpoint — webinar events not subscribed, Zoom deactivated the URL after repeated failures, a drifted server clock tripping the 300 s replay guard, or a rotated secret — the session shows the designed tell: **Live on the funnel's Sessions tab and Upcoming on its own Webinar tab at the same time**, because `Event::live_status` deliberately falls through to the clock while the webinar row is still Upcoming ([Event.php](/src/Event/Event.php)). Attendance stays empty, the session never reaches Completed, and the Attendance tab's Refresh cannot help — it is a `router.reload` that re-reads the local DB and never calls Zoom. Diagnose from the sanitized `Zoom webhook received.` log line: absent = nothing arrived, present without any `webinar.*` = the event subscription is missing them, `Zoom webhook rejected` = signature/clock/secret.
>
> **Recovery is `zoom:recover-webinar` ([RecoverZoomWebinar](/app/Console/Commands/RecoverZoomWebinar.php))**, which replays `handleWebinarEnded()` in the same order through the same repositories and jobs — nothing is lost, because Zoom keeps the participant report and it is addressable by the **numeric** webinar id, which is exactly what a missed `webinar.ended` (the sole writer of `zoom_webinar_uuid`) leaves us holding. **The report is fetched and asserted non-empty BEFORE any write**, and that guard is the whole point rather than a courtesy: `finalizeAttendance()` marks every zero-second registration No-show, and an unavailable report falls back to the local segments — empty in precisely this scenario — so recovering too early writes No-show across an entire 1,800-person session. `--force` is the deliberate escape hatch for a webinar nobody attended. `ended_at` is taken from the latest participant leave time, never the run time, because every after-it-ends funnel automation anchors on it (this is why `ZoomWebinarRepository::markEnded()` accepts an optional `$endedAt`; the webhook fires at the end and still passes nothing). The occurrence uuid is auto-picked by proximity to the scheduled start and **refused** — not guessed — when nothing lands near it, since the report family answers for one occurrence by numeric id without saying which. Covered by [tests/Feature/Zoom/RecoverZoomWebinarTest.php](/tests/Feature/Zoom/RecoverZoomWebinarTest.php).

## Prerequisites
- A Zoom **Server-to-Server OAuth** Marketplace app, **Activated**, with scopes for webinars (`webinar:read/write/delete:admin` + registrant), meetings (`meeting:read:meeting:admin` is what names a live meeting on the Live table), cloud recordings (`cloud_recording:read…`, also used for webinar replays), `report:read:admin` (attendance), `user:read:admin`, and — **optional** — `dashboard_webinars:read:admin` for the **live attendee count** (needs a Business+ plan; the Live banner just omits the count without it); plus an **Event Subscription** (webhook → `https://<host>/webhooks/zoom`) subscribed to the meeting + webinar events, with its **Secret Token**.
- For the **Live Copilot**: a second, **user-managed General App** with the RTMS scopes and its own Event Subscription (`https://<host>/webhooks/zoom-rtms`, events `meeting.rtms_started` / `rtms_stopped` / `rtms_interrupted`), its credentials as `ZOOM_RTMS_*` in `.env`, and the Zoom-side setup in the *Live Copilot (RTMS)* section above (app added for the users, auto-start on, host approval on).
- The credentials entered on **Manage → Integrations → Zoom** (Account ID, Client ID, Client Secret, `webinar_host_email` = a **Licensed** account user, Webhook secret token). `config/services.php` `zoom` (`base_url` / `oauth_url`, optional `.env` fallback for the secrets) is the fallback; the DB row is authoritative. Verify with `php artisan zoom:ping`.
- A **Gemini** global key (Manage → AI Providers) + a running queue worker / Horizon for recording analysis, webinar-registrant sync, and attendance reconcile (the `ai` and default queues).
- **`ZOOM_SYNC_ENABLED=true`** for the recordings sync. Since 2026-09-07 it runs on **two cadences** ([Kernel](/app/Console/Kernel.php)): `--days=1` **every 15 minutes** (a meeting that just ended reaches the Recordings page — and its Live Copilot tabs — as soon as Zoom finishes the cloud recording, the way Phone Call history polls) and the original `--days=3` at **03:00** as the catch-up. Both share the overlap lock and upsert on `zoom_recording_id`, so the cadence adds no per-run cost: AI transcription still only runs with `--analyze` (`ZOOM_AUTO_ANALYZE_NEW`).
- **`ZOOM_AUTO_MINE_AVATARS=true`** (optional, default off) for Client Avatars to mine every meeting whose transcript lands, then score it — see *Client Avatars* above. It relies on the sync (`ZOOM_SYNC_ENABLED`) to save the transcripts, a global **Anthropic** key, and — for meetings Zoom did not transcribe itself — `ZOOM_AUTO_ANALYZE_NEW`, so the AI fallback produces one. Re-run `php artisan config:cache` after changing it.
- The admins who schedule meetings / host recordings must be **users on the connected Zoom account** (their login email = a Zoom user).
  - **Recording sync keys on this email equality.** `ZoomRecordingSyncService::syncAll()` matches an admin to a Zoom account user **only when the admin's petav3 login email equals their Zoom account email**. A licensed Zoom user whose email matches no admin is **skipped** — `php artisan zoom:sync-recordings` prints the skipped emails (`Skipped N Zoom account user(s)…`) so the mismatch is visible, never silent. This holds by company policy (agents log in with their licensed Zoom email); if the two emails ever need to diverge (a non-agent admin, a per-deployment difference), add a nullable `admins.zoom_email` that falls back to the login email and key the match on that.

## Related files

**Backend — S2S core** (`src/Zoom/`)
- [src/Zoom/Services/ZoomServerService.php](/src/Zoom/Services/ZoomServerService.php) — the **only** Zoom API caller. Account token (cached, 401-retry) + generic `request()`; `isConfigured`/`webinarHost`; users (`getUser`, `listUsers`, cached `accountUsers`/`accountUserEmails`/`isAccountUser`); meetings; webinars; registrants; `getWebinarParticipantsReport`; `getWebinarLiveParticipants` (Dashboard live count, cached + best-effort); `getWebinarRecordingShareUrl`; recordings (`listUserRecordings`/`getPastMeetingParticipants`/`fetchRecordingFile`/`isZoomHost`).
- [src/Zoom/Services/ZoomCredentialProvider.php](/src/Zoom/Services/ZoomCredentialProvider.php) — resolves each credential DB-first then config; `DecryptException`-safe; `flush()` after writes.
- **Discovery (webinars the app did not create):** [src/Zoom/Services/ZoomWebinarDiscoveryService.php](/src/Zoom/Services/ZoomWebinarDiscoveryService.php) — month-stepped Path A + Path B, unioned by occurrence uuid; reports the six-month `wall` instead of throwing. `ZoomServerService` gained `listUserWebinars()` / `listPastWebinarInstances()` / `listUserMeetingsReport()` (+ `isMissingWebinarPlan()`); `ZoomWebinarRepository::upsertFromDiscovery()` writes the event-less row. Probe: [app/Console/Commands/ProbeZoomPolls.php](/app/Console/Commands/ProbeZoomPolls.php) (`zoom:probe-polls`, read-only).
- [src/Zoom/ZoomServerCredential.php](/src/Zoom/ZoomServerCredential.php) — single encrypted credentials row (`encrypted` cast + `$hidden` on the two secrets; `*_last4`; verify status); `current()`, `lastFour()`, `toSettingsArray()`.
- [src/Zoom/Repositories/ZoomServerCredentialRepository.php](/src/Zoom/Repositories/ZoomServerCredentialRepository.php) — `save()` (write-only secrets), `recordVerification()`.
- [src/Zoom/Exceptions/ZoomApiException.php](/src/Zoom/Exceptions/ZoomApiException.php) — user-safe exception carrying the HTTP status.

**Backend — Webinars, attendance & engagement** (`src/Zoom/`)
- [src/Zoom/ZoomWebinar.php](/src/Zoom/ZoomWebinar.php) — webinar per session (`event_id`; `STATUSES` incl. CREATING/FAILED; `ZOOM_TYPE_SCHEDULED`; `recording_url`/`recording_synced_at`); `event()`/`attendances()`; status accessors (`is_active`/`is_upcoming`/`is_live`/`is_ended`/`is_creating`/`is_failed`); `toShowArray()`.
- [src/Zoom/ZoomWebinarAttendance.php](/src/Zoom/ZoomWebinarAttendance.php) — raw join/leave segment log (no uuid/blame — webhook-written; `match_method` records the matcher tier that linked the segment); `webinar()`/`registration()`/`lead()`.
- [src/Zoom/Support/WebinarAttendeeMatcher.php](/src/Zoom/Support/WebinarAttendeeMatcher.php) — read-only participant→lead/registration resolver: `match($webinar, $participant, $allowNameMatch = false)` → `['event_registration_id','lead_id','method']`; tiers registrant-id → email → unique-name (name tier only when `$allowNameMatch`, reconcile-only). **Matches only — never creates.**
- [src/Zoom/Services/WebinarAttendeeLinker.php](/src/Zoom/Services/WebinarAttendeeLinker.php) — the create side: `linkByEmail($email, $name, $funnelId)` mints (or matches) one lead for an unmatched attendee via the shared `LeadLinker` (email-keyed, `TRUST_NONE`, attributed to the webinar's funnel; skips email-less / staff) — used by `ReconcileWebinarAttendance` after matching; plus `link` / `linkExisting` / `preview` / `unlinkedEmails` — the account-wide backfill API (writes lead_id onto the attendance rows via `ZoomWebinarAttendanceRepository::attachLeadByEmail`). Tested in [tests/Feature/Zoom/WebinarAttendeeLinkerTest.php](/tests/Feature/Zoom/WebinarAttendeeLinkerTest.php).
- [app/Console/Commands/LinkWebinarAttendees.php](/app/Console/Commands/LinkWebinarAttendees.php) — `zoom:link-webinar-attendees` (`--dry-run` / `--existing-only` / `--backdate` / `--limit`): the history backfill sweep over past webinar attendees (the attendance-side companion to `zoom:link-poll-respondents`, which carries the same flags). `--backdate` dates a lead the sweep CREATES to their earliest attendance/answer — see the note under "Two different jobs" above.
- [src/Zoom/Repositories/ZoomWebinarRepository.php](/src/Zoom/Repositories/ZoomWebinarRepository.php) — `create`/`createCreating`/`fulfill`/`markCreating`/`markFailed`/`update`/`cancel`/`delete`/`markLive`/`markEnded`/`setRecording`/`upsertFromDiscovery`/`adoptForEvent`/`firstOrCreateEventless`/`firstOrCreateFromReport` (the CSV-import shell row — **`start_time` is a caller requirement**, the column is NOT NULL and a null aborts the run mid-import).
- **CSV report import (past the six-month API wall):** [app/Console/Commands/ImportZoomReports.php](/app/Console/Commands/ImportZoomReports.php) — `zoom:import-reports` (`--dir=` / `--write` / `--timezone=` / `--skip-polls`); dry run by default, attendance pass before polls (only attendance creates a missing webinar), a Zoom id treated as a **series** not a sitting (`files()` keys on the filename, `findWebinar()` on the occurrence's start, ambiguity is skipped not guessed), an unknown `--timezone` refused up front, and a non-zero exit when any file could not be imported · [src/Zoom/Support/ZoomReportCsv.php](/src/Zoom/Support/ZoomReportCsv.php) — the sectioned-CSV parser (`attendees()` / `polls()` / `webinarIdFromFilename()`; BOM strip, per-section headers, offsetless local timestamps re-emitted with one, registrant rows without `Attended: Yes` excluded) · [src/Zoom/Support/PollResponseKeys.php](/src/Zoom/Support/PollResponseKeys.php) — the single definition of `questionKey()` / `respondentKey()` / `answerHash()` / `nonPlaceholder()` / `normalizeText()`, shared with `SyncWebinarResponses` so the two ingest paths cannot disagree about an idempotency key · `ZoomWebinarResponseRepository::adoptIdlessTwin()` — re-keys a CSV-imported (`noid|prompt`) question onto the API's (`poll_id|prompt`) key on a later sync, instead of growing a second copy · test [tests/Feature/Zoom/ImportZoomReportsTest.php](/tests/Feature/Zoom/ImportZoomReportsTest.php).
- [src/Zoom/Support/WebinarParticipantIdentity.php](/src/Zoom/Support/WebinarParticipantIdentity.php) — the single definition of participant identity (`forParticipant()` report-side / `forSegment()` segment-side; `PREFIX_EMAIL`/`PREFIX_ZOOM`/`PREFIX_SESSION`). **Email is the identity** — `id` is host/panelists only (3 of 92 rows) and `user_id` is per-session (92 ids for 62 people). Every consumer resolves through here; see the table + keyspace ⚠️ above.
- [app/Jobs/Zoom/SyncWebinarAttendance.php](/app/Jobs/Zoom/SyncWebinarAttendance.php) — the **standalone** (eventless) attendance pass: participant report → `syncFromReport()`, participation only, never attribution, never lead-minting. The counterpart to `ReconcileWebinarAttendance`, which now returns early on a null `event_id` and dispatches this instead.
- [app/Console/Commands/RecoverZoomWebinar.php](/app/Console/Commands/RecoverZoomWebinar.php) — `zoom:recover-webinar {zoom_webinar_id}` (`--uuid=` / `--ended-at=` / `--force` / `--dry-run` / `--sync`): replay a missed `webinar.ended` webhook — end the webinar, complete the session, snapshot its offers, store the occurrence uuid and rebuild attendance from Zoom's report. Refuses to write until the report returns participants (see the webhook section's warning).
- [app/Console/Commands/BackfillZoomWebinarAttendance.php](/app/Console/Commands/BackfillZoomWebinarAttendance.php) — `zoom:backfill-webinar-attendance` (`--dry-run` / `--relink-only`): relink orphaned dashboard rows via `linkWebinar()` + queue attendance for eventless webinars; `allowRetryOnEmpty: false`, idempotent.
- [src/Zoom/Repositories/ZoomWebinarAttendanceRepository.php](/src/Zoom/Repositories/ZoomWebinarAttendanceRepository.php) — `recordJoin`/`recordLeave` / `syncFromReport` (+ the private `reportRowKey()` **keyspace-convergence** lookup and `parseReportTime()`'s app-timezone conversion — both ⚠️ documented above) / `attributeSegments` / `finalizeAttendance` / `segmentSecondsByRegistration` (two-level aggregate: inner `MAX` per person/join-instant, outer `SUM`, so a duplicate segment cannot double a registration's minutes while a genuine re-join still accumulates). Attribution is **upgrade-only** via a confidence tier (`matchTier`: registrant 3 > email 2 > name 1 > unmatched 0): `attributeSegments` and `recordLeave` never DOWNGRADE a segment's match nor FLIP an already-attributed lead to a different person on an equal/lower tier (a reconcile name-match can't clobber a live registrant/email match; a leave that resolves unmatched can't wipe the join's lead), only ever adding attribution or upgrading — moving `lead_id` + registration + `match_method` together so the row never desyncs.
- [app/Actions/CreateSessionWebinarAction.php](/app/Actions/CreateSessionWebinarAction.php) — the shared create-then-persist recipe (`prepare` placeholder + `fulfill` Zoom-call + `dispatchForGenerated`) plus the session-bound lifecycle (`sync` re-syncs topic/schedule, `teardown` deletes at Zoom); used by the controller, the generation hook, the session update/cancel/delete path and the job.
- [app/Http/Controllers/Manage/Events/WebinarsController.php](/app/Http/Controllers/Manage/Events/WebinarsController.php) — session webinar `store` + `retry` only (create delegated to the Action; the webinar is bound to the session, so edit/cancel/delete happen via `EventsController`).
- [app/Http/Requests/Manage/Events/Webinar/StoreRequest.php](/app/Http/Requests/Manage/Events/Webinar/StoreRequest.php) — only `requires_registration` (topic + schedule come from the session).
- [app/Jobs/Zoom/CreateSessionWebinar.php](/app/Jobs/Zoom/CreateSessionWebinar.php) — queued auto-create (fulfils a CREATING placeholder; idempotent; retries) · [app/Jobs/Zoom/SyncWebinarRecording.php](/app/Jobs/Zoom/SyncWebinarRecording.php) — fetch + store an ended webinar's replay link · [app/Jobs/Zoom/SyncWebinarRegistrants.php](/app/Jobs/Zoom/SyncWebinarRegistrants.php) — the push (ShouldBeUnique, batch-capped, records every skip/failure on the row) · [app/Console/Commands/PushZoomRegistrants.php](/app/Console/Commands/PushZoomRegistrants.php) — `zoom:push-registrants`, its 5-minute catch-up sweep · [app/Jobs/Zoom/ReconcileWebinarAttendance.php](/app/Jobs/Zoom/ReconcileWebinarAttendance.php) — reconcile rollup; matches all participants (name on), creates walk-ins, backfills segments, finalizes · [app/Actions/EnrollLeadInFunnelSessionsAction.php](/app/Actions/EnrollLeadInFunnelSessionsAction.php) — the funnel-wide enrol · [app/Actions/SyncSessionWebinarRegistrantsAction.php](/app/Actions/SyncSessionWebinarRegistrantsAction.php) — the shared "register the lead on the session's Zoom webinar" step (every registration path).
- [app/Http/Controllers/Main/ZoomJoinController.php](/app/Http/Controllers/Main/ZoomJoinController.php) — the **public** per-lead join short link (`GET /zoom/{token}`, route `session.join`), the destination `{{event_location}}` actually sends. `show()` finds the registration by `event_registrations.join_token`, resolves the target **at click time** (personal `zoom_join_url` → the webinar's shared `join_url` → the session's static `zoom_link` — mirroring `FunnelWhatsappComposer::sessionLocation`), tallies the click (`EventRegistrationRepository::recordJoinClick` → `join_click_count` / `join_clicked_at`), writes `ActivityLog::TYPE_SESSION_JOIN_CLICKED` on the lead's trail (actor null — the lead is almost never signed in when they tap a WhatsApp link), then `redirect()->away()`. **No middleware and no signature**: the 16-char token *is* the bearer credential. A cancelled session, an unknown/stale token or nowhere to send them falls back to `landing` instead of dead-ending.
- **Engagement (poll / quiz / Q&A):** [src/Zoom/ZoomPoll.php](/src/Zoom/ZoomPoll.php) — canonical poll (unique `poll_id`, `title`, `poll_type`, `TYPES`; `questions()`) · [src/Zoom/Repositories/ZoomPollRepository.php](/src/Zoom/Repositories/ZoomPollRepository.php) — `upsert()` (resolve/refresh the canonical poll by `poll_id`) · [src/Zoom/ZoomWebinarQuestion.php](/src/Zoom/ZoomWebinarQuestion.php) — one row per question/webinar/`kind` (`KIND_*`, `POLL_TYPE_*`, `zoom_poll_id` + `poll()`, the `question_type` → render-family map) · [src/Zoom/ZoomWebinarResponse.php](/src/Zoom/ZoomWebinarResponse.php) — one row per participant answer (`is_correct` derived from config) · [src/Zoom/Repositories/ZoomWebinarResponseRepository.php](/src/Zoom/Repositories/ZoomWebinarResponseRepository.php) — `syncFromReports()` (resolves `zoom_poll_id`, upserts questions + responses, idempotent) · [app/Jobs/Zoom/SyncWebinarResponses.php](/app/Jobs/Zoom/SyncWebinarResponses.php) — chained after reconcile; report + config merge; retries on report lag. The `engagement` prop is built by `EventsController@show` (`buildEngagementProp`). Cross-webinar: [app/Http/Controllers/Manage/Zoom/PollsController.php](/app/Http/Controllers/Manage/Zoom/PollsController.php) — the Polls index + a per-poll responses table across all its webinars, plus `sync()` (dispatches the backfill) (`manage.zoom.polls.*`). Backfill: [app/Actions/BackfillWebinarPollsAction.php](/app/Actions/BackfillWebinarPollsAction.php) (the shared discover→sync recipe) · [app/Jobs/Zoom/BackfillWebinarPolls.php](/app/Jobs/Zoom/BackfillWebinarPolls.php) (queued wrapper; `WithoutOverlapping`) · [app/Console/Commands/SyncZoomPolls.php](/app/Console/Commands/SyncZoomPolls.php) (`zoom:sync-polls`, inline by default) · tests [tests/Feature/Zoom/ZoomPollBackfillTest.php](/tests/Feature/Zoom/ZoomPollBackfillTest.php). Migrations: [create_zoom_polls_table](/database/migrations/2026_07_09_000001_create_zoom_polls_table.php) + [add_zoom_poll_id_to_zoom_webinar_questions_table](/database/migrations/2026_07_09_000002_add_zoom_poll_id_to_zoom_webinar_questions_table.php) + [add_uuid_to_zoom_polls_table](/database/migrations/2026_07_13_000003_add_uuid_to_zoom_polls_table.php) (route-bind the cross-webinar page).
- **Chat (manual `.txt` upload — the only manual-ingress path):** [src/Zoom/Support/ChatFileParser.php](/src/Zoom/Support/ChatFileParser.php) — pure static parser (current + legacy `From "sender" to target:` header formats; multi-line messages join into one row; returns `line_index` / `sender_name` / `target` / `message` / `sent_at`) · [src/Zoom/ZoomWebinarChat.php](/src/Zoom/ZoomWebinarChat.php) — one row per chat message (child table — no uuid/blame/softDeletes; `METHOD_*`/`METHODS` **aliased** from `ZoomWebinarAttendance`; `webinar()`/`lead()`/`eventRegistration()`) · [src/Zoom/Repositories/ZoomWebinarChatRepository.php](/src/Zoom/Repositories/ZoomWebinarChatRepository.php) — `syncFromUpload()` (build roster + prepare rows outside the transaction, then delete-all-for-webinar + per-row `create`; roster name-match via `WebinarAttendeeMatcher::normalizeName()`) · [app/Http/Controllers/Manage/Events/WebinarChatController.php](/app/Http/Controllers/Manage/Events/WebinarChatController.php) — single `store` (resolves the **event** by uuid, soft-guards a missing webinar; `permission:MANAGE_EVENTS`) · [app/Http/Requests/Manage/Events/Webinar/UploadChatRequest.php](/app/Http/Requests/Manage/Events/Webinar/UploadChatRequest.php) (`file|mimes:txt|max:10240`) · migration [create_zoom_webinar_chats_table](/database/migrations/2026_07_14_000002_create_zoom_webinar_chats_table.php) · test [tests/Feature/Zoom/ZoomWebinarChatTest.php](/tests/Feature/Zoom/ZoomWebinarChatTest.php). See the [Webinar chat](/docs/modules_handbook/manage/zoom/webinar-chat/readMe.md) handbook.

**Backend — Attendance dashboard + no-show export** (Events portal; reads only)
- [app/Http/Controllers/Concerns/LoadsWebinarAttendance.php](/app/Http/Controllers/Concerns/LoadsWebinarAttendance.php) — shared trait; `noShowRegistrations(Event)` is the single source of truth used by **both** the `attendance` prop and the export, so the on-screen no-show list and the file never drift.
- [app/Http/Controllers/Manage/Events/WebinarAttendanceController.php](/app/Http/Controllers/Manage/Events/WebinarAttendanceController.php) — thin export controller (`use ExportsResource, LoadsWebinarAttendance`); `exportNoShows($id)` → `downloadExport(new WebinarNoShowExport(...), 'webinar-no-shows', $format)` (xlsx/csv).
- [app/Exports/WebinarNoShowExport.php](/app/Exports/WebinarNoShowExport.php) — maatwebsite export (`FromCollection`+`WithHeadings`+`WithMapping`+`ShouldAutoSize`); mirrors `LeadsExport`.
- The `attendance` prop itself (live/final roster, no-show list, unmatched) is built by `EventsController@show` — see the [Events · Funnels](/docs/modules_handbook/manage/events/funnels/readMe.md) doc.

**Backend — Meetings** (`src/Zoom/`)
- [src/Zoom/ZoomMeeting.php](/src/Zoom/ZoomMeeting.php) — meeting (`STATUSES` + the derived `DISPLAY_PAST`; `SOURCE_APP`/`SOURCE_SYNC`/`SOURCE_WEBINAR`; AI columns; `TRANSCRIPT_*` / `TRANSCRIPT_SOURCES` constants for the AI fallback lifecycle); `agent()`/`lead()`/`recordings()`/`audioFile()`/`webinar()`; `hasEndedByTime()` / `isPast()` (drive the "Past" label + suppress actions); `isWebinar()` (the `source === SOURCE_WEBINAR` discriminator — `zoom_webinar_id` is only the FK/idempotency key to the backing `ZoomWebinar`); the `'meetingsOnly'` global scope (`booted()`, `where('source','!=',SOURCE_WEBINAR)`) + `scopeWithWebinars()` opt-in; `toShowArray()`/`toCalendarArray()`/`toDetailArray()` (includes `transcript_source`, `transcript_status`, `is_webinar`, label/color props for the Vue tab).
- [src/Zoom/Repositories/ZoomMeetingRepository.php](/src/Zoom/Repositories/ZoomMeetingRepository.php) — `create`/`update`/`cancel`/`delete`/`markLive`/`markEnded`/`createOrUpdateFromSync`/`createOrUpdateFromWebinarSync` (the ONE canonical webinar upsert shared by BOTH ingestion paths — keyed on the local `zoom_webinar_id`, catch-and-requery SQLSTATE-23000 race guard)/`saveTranscript`/`saveAnalysis`/`setAiStatus`/`linkLead`/`linkWebinar` (bind a dashboard row to its backing `ZoomWebinar` — transactional, idempotent, returns the refreshed model; used by `RecordingsController@syncAttendance` and the backfill command so neither writes the FK by hand)/`setTranscriptStatus`/`markTranscriptProcessingIfEligible` (atomic claim — see §4.2 of `zoomtranscriptionfallback.md`).
- [app/Http/Controllers/Manage/Leads/ZoomMeetingsController.php](/app/Http/Controllers/Manage/Leads/ZoomMeetingsController.php) — lead-scoped `store` (host = acting admin email; `isAccountUser` gate).
- [app/Http/Controllers/Manage/ZoomMeetingsController.php](/app/Http/Controllers/Manage/ZoomMeetingsController.php) — `update`/`cancel`/`start` (`isAccountUser` + ownership).
- [app/Http/Controllers/Manage/Calendar/CalendarZoomMeetingsController.php](/app/Http/Controllers/Manage/Calendar/CalendarZoomMeetingsController.php) — calendar `store`/`update`/`cancel`/`start`/`destroy`/`invitation` (optional lead attach, no Closer gate). See [Calendar](/docs/modules_handbook/manage/calendar/readMe.md).
- Form Requests: [Leads/ZoomMeetings/StoreRequest.php](/app/Http/Requests/Manage/Leads/ZoomMeetings/StoreRequest.php) · [ZoomMeetings/UpdateRequest.php](/app/Http/Requests/Manage/ZoomMeetings/UpdateRequest.php) · [Calendar/ZoomMeetings/*](/app/Http/Requests/Manage/Calendar/ZoomMeetings/).

**Backend — Recordings + AI** (`src/Zoom/`, `app/`)
- [src/Zoom/ZoomRecording.php](/src/Zoom/ZoomRecording.php) — cached recording file (no uuid); `TYPES`/`VIDEO_TYPES`/`AUDIO_TYPES`/`TRANSCRIPT_TYPES`; `isStreamable()` (true when archived OR a `download_url` exists); `toFileArray()`/`groupForViewer()`. Durable-archive additions: `ARCHIVE_NONE`/`ARCHIVE_PROCESSING`/`ARCHIVE_DONE`/`ARCHIVE_FAILED` (+ `ARCHIVE_STATUSES`), `ARCHIVABLE_TYPES` (`VIDEO_TYPES` + `AUDIO_TYPES`), `isArchived()` (`media_id !== null`, authoritative), `isArchivable()`, `media(): BelongsTo` (no schema FK).
- [src/Zoom/Repositories/ZoomRecordingRepository.php](/src/Zoom/Repositories/ZoomRecordingRepository.php) — idempotent `createOrUpdateManyFromZoom` (shared by sync + webhook). Durable-archive additions: `markArchiveProcessingIfEligible()` (atomic claim — conditional `UPDATE` on `archive_status <> ARCHIVE_PROCESSING`, force-aware media_id check), `attachMedia()` (point `media_id` at the new copy, `archive_status = DONE`, `archived_at`), `setArchiveStatus()` — both `refresh()` the model first (the claim mutates the row via a separate query, so the caller's in-memory copy can be stale — see the [zoom-recording-media-archiving.md](/plan_doc/zoom-recording-media-archiving.md) plan for the bug this fixed).
- [src/Zoom/Services/ZoomRecordingSyncService.php](/src/Zoom/Services/ZoomRecordingSyncService.php) — account-level sync coordinator: `syncAgent`/`syncAll` (loops **Zoom-account-user** admins; `syncAgent` also routes **standalone** webinar recordings — Zoom types 5/6/9, `WEBINAR_ZOOM_TYPES` — through `firstOrCreateEventless` + the shared `createOrUpdateFromWebinarSync`, so they converge with the Event-linked path on one row) / `queuePendingAnalysis` + `queuePendingProcessing` (meetings-only scope → never selects a webinar; the latter backs the batch transcribe+analyze action, see the RecordingsController entry below) / `maybeFetchTranscript` (VTT-first, AI fallback; increments `transcripts` or `transcriptions_queued` counter) / `syncWebinar` (Event-linked webinar recording → dashboard, host-matched only, never AI) / `adminsByEmail()` (shared host-matching helper) / `queueArchiving()` (protected — auto-dispatches `ArchiveZoomRecording` after every recording upsert, gated on `services.zoom.archive.enabled`; see *Durable GCS archive* above).
- [src/Zoom/Services/WebinarEngagementBuilder.php](/src/Zoom/Services/WebinarEngagementBuilder.php) — the shared engagement-payload builder (summary + Poll/Quiz/Q&A/Chat), used by BOTH the Events page (`EventsController::buildEngagementProp`) and the Recordings detail (`RecordingsController@show`). See *Reference usage* above.
- [src/Zoom/Repositories/ZoomWebinarRepository.php](/src/Zoom/Repositories/ZoomWebinarRepository.php) — `firstOrCreateEventless()` (mints/reuses an `event_id`-null `ZoomWebinar` for a standalone recording, keyed on the Zoom string id, for the recordings dashboard) and `upsertFromDiscovery()` (the account-discovery/backfill path, keyed on the occurrence uuid — `SOURCE_DISCOVERED`) alongside the Event-flow webinar CRUD.
- [src/Zoom/Support/VttParser.php](/src/Zoom/Support/VttParser.php) — `.vtt` → plain transcript.
- [app/Jobs/Zoom/TranscribeZoomMeeting.php](/app/Jobs/Zoom/TranscribeZoomMeeting.php) — AI transcription fallback, Gemini → Deepgram (`redis-transcription` lane; streams via `transcribeFile`; atomic claim + `WithoutOverlapping`; `failed()` hook; dispatches `AnalyzeZoomMeeting` on success). See the [Transcription](/docs/modules_handbook/shared/transcription/readMe.md) handbook.
- [app/Jobs/Ai/AnalyzeZoomMeeting.php](/app/Jobs/Ai/AnalyzeZoomMeeting.php) — runs the shared `ConversationAnalyzer` (`AiJob` resilient lane) → unified `ai_analysis`; a `failed()` hook resets a meeting stuck at `AI_PROCESSING` to `AI_FAILED` once retries are exhausted. zh via the `translate-analysis` route (TranslatesAnalysis trait).
- [app/Jobs/Zoom/SyncWebinarRecordingToMeeting.php](/app/Jobs/Zoom/SyncWebinarRecordingToMeeting.php) — queued job wrapping `syncWebinar()`; triggered from the same two places as `SyncWebinarRecording` (recording.completed webhook + the Events page lazy fallback).
- [app/Console/Commands/SyncZoomWebinarRecordings.php](/app/Console/Commands/SyncZoomWebinarRecordings.php) — one-time backfill (`zoom:sync-webinar-recordings`; candidates = ended webinars with an available cloud recording; reports skipped-unmatched-host emails).
- [app/Http/Controllers/Manage/Zoom/RecordingsController.php](/app/Http/Controllers/Manage/Zoom/RecordingsController.php) — all-admin dashboard `index`/`sync`/`analyze`/`processBatch`/`link`/`transcribe`/`syncEngagement`/`syncAttendance` (§14 DataTable; `sync()` calls `syncAll(…, analyze: true)` so "Sync now" runs the full fetch → transcribe → analyze pipeline). There is **no standalone Show page** — the "View" action opens a modal fed by the lazy `detail` prop, which `index()` fills via the private `resolveDetail()` → `ZoomRecordingDetailBuilder` only when `?detail={uuid}` is present (the F2F-showroom pattern). Also carries webinar rows (`->withWebinars()` opt-in on `index`/the detail path/`syncEngagement`/the guarded actions), the webinar never-AI guards, the **inline** engagement payload via `WebinarEngagementBuilder` (built into the detail payload), the on-demand `syncEngagement` (`POST …/sync-engagement` → runs `SyncWebinarResponses` inline), the on-demand `syncAttendance` (`POST …/sync-attendance` → `linkWebinar()` + `SyncWebinarAttendance` inline, wrapped in a logged `catch` so a scope error / race / bug is distinguishable in the log rather than collapsing into one opaque flash), and the `type`/`typeCounts` **tab** partition. `processBatch()` (zoom-recordings-batch-pipeline.md) replaces the old analyze-only `analyzeBatch()`: it queues both transcribe + analyze via `ZoomRecordingSyncService::queuePendingProcessing()`, built on the same `visibleRecordingsQuery()` base as `index()` for guaranteed filter parity.
- [app/Http/Controllers/Manage/Zoom/ZoomRecordingStreamController.php](/app/Http/Controllers/Manage/Zoom/ZoomRecordingStreamController.php) — GCS-first playback: `show()` serves an archived recording (`media_id` set) from the durable copy with hand-rolled byte-range handling (`streamFromDisk()` — `200`/`206`/`416`, `readStream()`+`fseek()` by default; `streamFromSignedUrl()` — the `stream_via_signed_url` opt-in, a ranged fetch against a signed URL), else falls back to `streamFromZoom()` (the original account-token + Range + `isZoomHost` proxy, byte-for-byte unchanged); operates on the unscoped `ZoomRecording` model and never derefs `->meeting`.
- [app/Jobs/Zoom/ArchiveZoomRecording.php](/app/Jobs/Zoom/ArchiveZoomRecording.php) — the durable-archive job: download → `storeFromPath` → verify size vs. `file_size` → attach (swap) → delete the superseded copy; force-aware idempotency + atomic claim; `redis-video` lane (config-driven connection); `WithoutOverlapping`; `failed()` hook resets a stranded row (guarded so it never overwrites an already-`ARCHIVE_DONE` row).
- [app/Console/Commands/ArchiveZoomRecordings.php](/app/Console/Commands/ArchiveZoomRecordings.php) — `zoom:archive-recordings` backfill/sweep command (`--days`/`--agent`/`--meeting`/`--type`/`--limit`/`--force`/`--dry-run`/`--sync`); runs regardless of `archive.enabled`; `--force` dispatches with the job's `$force` flag set so a re-archive isn't a no-op.
- Tests: [tests/Feature/Zoom/ArchiveZoomRecordingJobTest.php](/tests/Feature/Zoom/ArchiveZoomRecordingJobTest.php) (full job matrix incl. force-swap + cleanup-debt) · [tests/Feature/Zoom/ArchiveZoomRecordingsCommandTest.php](/tests/Feature/Zoom/ArchiveZoomRecordingsCommandTest.php) · [tests/Feature/Zoom/ZoomRecordingSyncArchiveHookTest.php](/tests/Feature/Zoom/ZoomRecordingSyncArchiveHookTest.php) · [tests/Feature/Zoom/ZoomRecordingRepositoryArchiveTest.php](/tests/Feature/Zoom/ZoomRecordingRepositoryArchiveTest.php) · [tests/Feature/Zoom/ZoomRecordingStreamControllerGcsTest.php](/tests/Feature/Zoom/ZoomRecordingStreamControllerGcsTest.php) (default byte-range path, exact-bytes assertions) · [tests/Unit/Zoom/ZoomRecordingStreamControllerSignedUrlTest.php](/tests/Unit/Zoom/ZoomRecordingStreamControllerSignedUrlTest.php) (signed-URL mock seam) · [tests/Unit/Zoom/ZoomRecordingArchiveStateTest.php](/tests/Unit/Zoom/ZoomRecordingArchiveStateTest.php) · [tests/Unit/Zoom/ZoomServerServiceDownloadTimeoutTest.php](/tests/Unit/Zoom/ZoomServerServiceDownloadTimeoutTest.php).
- [app/Http/Controllers/Manage/Leads/ZoomRecordingsController.php](/app/Http/Controllers/Manage/Leads/ZoomRecordingsController.php) · [app/Http/Controllers/Manage/People/AdminZoomRecordingsController.php](/app/Http/Controllers/Manage/People/AdminZoomRecordingsController.php) — per-lead / per-admin sync (`isAccountUser` gated); stay on the default meetings-only scope (never opt into `->withWebinars()`).
- [app/Http/Requests/Manage/Zoom/RecordingsQueryRequest.php](/app/Http/Requests/Manage/Zoom/RecordingsQueryRequest.php) — dashboard filters incl. `type` (the Meetings|Webinars **tab** discriminator — `'webinar'` vs `'meeting'`-or-**absent**; `apply()` injects the `meeting` default because the base `QueryRequest` never dispatches a filter for an absent param, and `filterType()` → `where('source', …)`) and `transcript_status` (multi-select on the transcript_status column, a cost gate for the batch action). Also extended (no own rules()) by [app/Http/Requests/Manage/Zoom/ProcessBatchRequest.php](/app/Http/Requests/Manage/Zoom/ProcessBatchRequest.php) so the batch POST reuses the exact same filters.
- Migrations: [add_zoom_webinar_id_to_zoom_meetings_table](/database/migrations/2026_07_15_000001_add_zoom_webinar_id_to_zoom_meetings_table.php) (nullable-unique FK/idempotency key) + [make_event_id_nullable_on_zoom_webinars](/database/migrations/2026_07_16_000002_make_event_id_nullable_on_zoom_webinars.php) (eventless standalone webinars).
- Commands: [app/Console/Commands/SyncZoomRecordings.php](/app/Console/Commands/SyncZoomRecordings.php) (`zoom:sync-recordings`; `--analyze` enables the transcription + analysis pipeline; `--transcribe-only` queues `TranscribeZoomMeeting` for existing meetings with no transcript and no full re-sync) · [app/Console/Commands/AnalyzeZoomRecordings.php](/app/Console/Commands/AnalyzeZoomRecordings.php) · [app/Console/Commands/ZoomPing.php](/app/Console/Commands/ZoomPing.php) (verify S2S) · [app/Console/Commands/SyncZoomPolls.php](/app/Console/Commands/SyncZoomPolls.php) (`zoom:sync-polls` — the poll backfill) · [app/Console/Commands/ProbeZoomPolls.php](/app/Console/Commands/ProbeZoomPolls.php) (`zoom:probe-polls` — read-only API probe) · scheduler in [app/Console/Kernel.php](/app/Console/Kernel.php).

**Backend — Client Avatars** (`src/Zoom/`, `app/`) — see *Client Avatars* above
- [src/Zoom/ZoomClientAvatar.php](/src/Zoom/ZoomClientAvatar.php) — one avatar per mined source (`zoom_meeting_id` or `call_recording_id`); `STATUSES` (pending / ready / failed / skipped), `OUTCOMES`, `MIN_CALL_SECONDS`; `avatar` + `scenario` JSON.
- [src/Zoom/Repositories/ZoomClientAvatarRepository.php](/src/Zoom/Repositories/ZoomClientAvatarRepository.php) — `saveMined` (idempotent upsert; replaces `scenario`) / `markFailed` / `mergeScenario` (row-locked — the two scores merge concurrently) / `mergeAvatar` / `delete`.
- [app/Jobs/Zoom/MineClientAvatar.php](/app/Jobs/Zoom/MineClientAvatar.php) — mines one Zoom meeting; `queueForNewTranscript()` is the switch-gated automatic entry; a READY result queues both scores · [MineCallAvatar.php](/app/Jobs/Zoom/MineCallAvatar.php) — the phone-call sibling (same prompt, model and chain).
- [app/Jobs/Zoom/MineJourneyScore.php](/app/Jobs/Zoom/MineJourneyScore.php) — 顾问分 → `scenario.journey` · [ScoreCustomerReadiness.php](/app/Jobs/Zoom/ScoreCustomerReadiness.php) — 客户分 → `scenario.crs` · [ScoreClientAvatar.php](/app/Jobs/Zoom/ScoreClientAvatar.php) — the difficulty / location backfill.
- Commands: [MineZoomClientAvatars](/app/Console/Commands/MineZoomClientAvatars.php) (`zoom:mine-avatars`) · [MineCallClientAvatars](/app/Console/Commands/MineCallClientAvatars.php) (`calls:mine-avatars`) · [ScoreJourneyPerformance](/app/Console/Commands/ScoreJourneyPerformance.php) (`avatars:journey-score`) · [ScoreCustomerReadinessCommand](/app/Console/Commands/ScoreCustomerReadinessCommand.php) (`avatars:crs`) · [ScoreClientAvatars](/app/Console/Commands/ScoreClientAvatars.php) (`avatars:score`).
- Page: [ClientAvatarsController](/app/Http/Controllers/Manage/Zoom/ClientAvatarsController.php) + [ClientAvatarsQueryRequest](/app/Http/Requests/Manage/Zoom/ClientAvatarsQueryRequest.php) → [Pages/Manage/Zoom/Avatars/Index.vue](/resources/js/Pages/Manage/Zoom/Avatars/Index.vue) + [Partials/AvatarDossier.vue](/resources/js/Pages/Manage/Zoom/Avatars/Partials/AvatarDossier.vue).
- Prompts: [zoom_avatar_mining.md](/resources/prompts/zoom_avatar_mining.md) · [zoom_journey_scoring.md](/resources/prompts/zoom_journey_scoring.md) · [zoom_crs_scoring.md](/resources/prompts/zoom_crs_scoring.md) · [zoom_avatar_scoring.md](/resources/prompts/zoom_avatar_scoring.md).
- Tests: [tests/Feature/Zoom/ClientAvatarAutoScoringTest.php](/tests/Feature/Zoom/ClientAvatarAutoScoringTest.php) — both transcript paths queue mining only when switched on; a mined consultation (Zoom or call) queues both scores, a skipped meeting none; two score merges never overwrite each other.

**Backend — Settings + Webhook**
- [app/Http/Controllers/Manage/Integrations/ZoomSettingsController.php](/app/Http/Controllers/Manage/Integrations/ZoomSettingsController.php) — `index`/`users`/`update` (save-always + soft-test)/`test`.
- [app/Http/Requests/Manage/Integrations/Zoom/UpdateRequest.php](/app/Http/Requests/Manage/Integrations/Zoom/UpdateRequest.php).
- [app/Http/Controllers/Webhooks/ZoomWebhookController.php](/app/Http/Controllers/Webhooks/ZoomWebhookController.php) — single signed endpoint; meeting + webinar + recording events.

**Frontend (Vue)** (`resources/js/`)
- [Pages/Manage/Integrations/Zoom/Index.vue](/resources/js/Pages/Manage/Integrations/Zoom/Index.vue) — `ShowTabs`: Connection (credentials form, status, webhook URL) + Users; [Partials/UsersTab.vue](/resources/js/Pages/Manage/Integrations/Zoom/Partials/UsersTab.vue) (lazy account-users list).
- [Pages/Manage/Events/Partials/Tabs/WebinarTab.vue](/resources/js/Pages/Manage/Events/Partials/Tabs/WebinarTab.vue) — the session's bound webinar, state-aware: creating/failed (retry), Upcoming (links/passcode/registrant progress), Live (attendee count), Ended (attendance summary + recording replay). Create is a direct action (no form — topic follows the session); there is no edit/cancel modal. The full attendee roster is on the session's [RegistrationsTab.vue](/resources/js/Pages/Manage/Events/Partials/Tabs/RegistrationsTab.vue).
- [Pages/Manage/Events/Partials/Tabs/AttendanceTab.vue](/resources/js/Pages/Manage/Events/Partials/Tabs/AttendanceTab.vue) — the session's **Attendance** tab: live roster, the full roster (now the shared [Components/WebinarEngagement/AttendanceRoster.vue](/resources/js/Components/WebinarEngagement/AttendanceRoster.vue), replacing ~70 lines of duplicated `DataTable` markup), no-show list + `ExportMenu`, unmatched participants; self-refreshes via `router.reload({ only: ['attendance'] })` (auto-polls while Live). The other three tables stay here — they all depend on `EventRegistration`s, which a standalone webinar has none of.
- [Components/WebinarEngagement/AttendanceRoster.vue](/resources/js/Components/WebinarEngagement/AttendanceRoster.vue) — the shared "who attended" roster, consumed by **both** the Events Attendance tab and the Recordings [RecordingDetail/Tabs/AttendanceTab.vue](/resources/js/Components/RecordingDetail/Tabs/AttendanceTab.vue). Optional columns are **prop-gated** (`showLeft` / `showMatch` / `showSource`) and duration accepts either `duration_label` (Recordings) or `duration_minutes` (Events), so introducing it changed **no** backend payload.
- [Pages/Manage/Events/Partials/Tabs/EngagementTab.vue](/resources/js/Pages/Manage/Events/Partials/Tabs/EngagementTab.vue) — the session's **Engagement** tab: renders the shared [Components/WebinarEngagement/WebinarEngagementPanel.vue](/resources/js/Components/WebinarEngagement/WebinarEngagementPanel.vue) (+ `EngagementSummary` / `PollResults` / `QaResults` / `ChatResults` / `PollQuestionCard`, **relocated** here from the Events partials so both the Events tab and the Recordings `PollQnaTab` share them): poll / quiz / Q&A / chat results in nested Lead Engagement / Polls / Q&A / Chat sub-tabs (`ShowTabs`, `esection` deep-link param); shown once the webinar has ended, with a "Re-sync from Zoom" action. The **Chat** sub-tab ([Components/WebinarEngagement/ChatResults.vue](/resources/js/Components/WebinarEngagement/ChatResults.vue)) is the `.txt` upload dropzone / "Re-upload" form + stat cards + the All-messages / By-sender message log (uploads via Inertia to the prop-supplied `upload_url`, then `router.reload({ only: ['engagement'] })`).
- [Pages/Manage/Zoom/Polls/Index.vue](/resources/js/Pages/Manage/Zoom/Polls/Index.vue) + [Polls/Show.vue](/resources/js/Pages/Manage/Zoom/Polls/Show.vue) — the **Zoom → Polls** cross-webinar view: every canonical poll (Poll / Type / Responses + View), and one poll's every response (one row per answer, each with its lead + webinar) across all the webinars it ran in.
- [Pages/Manage/Leads/Partials/Tabs/ZoomTab.vue](/resources/js/Pages/Manage/Leads/Partials/Tabs/ZoomTab.vue) (split into "Zoom Meeting" + "Zoom Webinar" sections) + [ZoomMeetingsPanel.vue](/resources/js/Pages/Manage/Leads/Partials/ZoomMeetingsPanel.vue) + [ZoomMeetingModal.vue](/resources/js/Pages/Manage/Leads/Partials/ZoomMeetingModal.vue) + [WebinarAttendancePanel.vue](/resources/js/Pages/Manage/Leads/Partials/WebinarAttendancePanel.vue) (the lead's webinars-joined list, registrant + walk-in rows, + a webinars-registered / total-watched summary) + [WebinarResponsesPanel.vue](/resources/js/Pages/Manage/Leads/Partials/WebinarResponsesPanel.vue) (that lead's own poll / quiz / Q&A answers) — lead Zoom tab (shown when `zoom.is_zoom_user` **or** the lead has webinar history).
- [Components/ZoomRecordingsPanel.vue](/resources/js/Components/ZoomRecordingsPanel.vue) + [ZoomRecordingModal.vue](/resources/js/Components/ZoomRecordingModal.vue) — shared recordings list + inline player (Admin page). **The Lead page no longer uses it** (2026-09-17): its Meetings tab shows each recording INSIDE its meeting's card in `ZoomMeetingsPanel.vue` — a recording group's `key` is its meeting's `uuid` — with **View recording** opening the full detail modal and Refresh in the card header; a recording whose meeting is not on the lead keeps a card of its own. **Join link / Copy show only while `is_joinable`** (`ZoomMeeting::toShowArray()`: LIVE, or UPCOMING and not lapsed) — an ended meeting's link joins nothing.
- [Pages/Manage/Zoom/Recordings/Index.vue](/resources/js/Pages/Manage/Zoom/Recordings/Index.vue) + [Components/ZoomRecordingDetailModal.vue](/resources/js/Components/ZoomRecordingDetailModal.vue) + [Components/RecordingDetail/](/resources/js/Components/RecordingDetail/) — the recordings dashboard (with the "Transcribe + analyze filtered" batch action) + the fetch-on-open **View** detail modal (`?detail={uuid}` partial reload; no standalone Show page) hosting the **shared tabbed detail reused by Zoom, Phone Call AND Showroom F2F** (Zoom passes `analyzeUrl` / `transcribeUrl` / `translateUrl`). Tabs for a meeting/call/F2f recording: **Overview / Transcript / AI analysis / Customer / Sales performance / Meeting report** (the analysis is split per-section via [`AnalysisSections.vue`](/resources/js/Components/RecordingDetail/AnalysisSections.vue); there is no longer a separate "Action Items" tab — those next-steps live in Meeting report, and `action_items` is no longer emitted by `toDetailArray()`). **Sales performance now also carries the HUMAN review layer** below the unchanged AI block ([`SalesPerformanceTab.vue`](/resources/js/Components/RecordingDetail/Tabs/SalesPerformanceTab.vue) — `ReviewList` + `ReviewForm`, the latter only when `can_review`), and the tab itself now appears whenever `hasAnalysis || canReview || hasReviews` (not `hasAnalysis` alone) — a recording whose AI analysis failed, was skipped, or never ran still needs somewhere to put a human review. Full detail: [Conversation Analysis → Human review](/docs/modules_handbook/shared/conversation-analysis/readMe.md#human-review-the-layer-below-the-ai-score). For a **webinar** row (`recording.is_webinar`) the tab set is instead **Overview / Transcript / Poll & Q&A / Attendance** ([Components/RecordingDetail/Tabs/PollQnaTab.vue](/resources/js/Components/RecordingDetail/Tabs/PollQnaTab.vue) — the **full inline** engagement via the shared `WebinarEngagementPanel` + a "Sync poll & Q&A from Zoom" button — and [Tabs/AttendanceTab.vue](/resources/js/Components/RecordingDetail/Tabs/AttendanceTab.vue) — the shared `AttendanceRoster` + a "Sync attendance from Zoom" button; never the AI/Customer/Sales/Report tabs). [Components/RecordingDetail/Tabs/TranscriptTab.vue](/resources/js/Components/RecordingDetail/Tabs/TranscriptTab.vue) shows four states (transcript present with optional AI provenance badge / processing spinner / failed + Retry button / empty + Transcribe button; the transcribe endpoint is now a `transcribeUrl` prop). `Index.vue` renders a distinct "Webinar" source chip, an N/A AI-status chip for webinar rows, and the **Meetings | Webinars tab strip** backed by the `type` dimension (default `meeting`, so a bare visit sends no param — see the ⚠️ above); the Webinars tab swaps the meeting-only Agent / Lead / AI columns for an **Attendance** count. The detail modal hides the link/unlink lead UI entirely for a webinar row (a webinar has many attendees, not one lead).
- [Pages/Manage/People/Admins/Index.vue](/resources/js/Pages/Manage/People/Admins/Index.vue) — the `is_zoom_user` Zoom icon beside an admin's email; [Admins/Show.vue](/resources/js/Pages/Manage/People/Admins/Show.vue) — Zoom recordings tab (gated on `admin.is_zoom_user`).
- [Layouts/ManageLayout.vue](/resources/js/Layouts/ManageLayout.vue) — "Zoom Settings" + "Zoom Recordings" nav entries.

**Config**
- [config/services.php](/config/services.php) — `zoom` block: `base_url` / `oauth_url`, `webinar_host_email` / the secrets (as **.env fallback**; the DB row is authoritative), `webhook_secret_token`. Nested `zoom.archive.*` block for the durable GCS archive — see *Durable GCS archive* above. `auto_mine_avatars` (`ZOOM_AUTO_MINE_AVATARS`, default off) — the Client Avatars switch, see *Client Avatars* above.

**Migrations** (`database/migrations/`)
- [2026_06_23_000004_create_zoom_server_credentials_table.php](/database/migrations/2026_06_23_000004_create_zoom_server_credentials_table.php) (+ [000005_add_verify_status…](/database/migrations/2026_06_23_000005_add_verify_status_to_zoom_server_credentials.php)) — the encrypted S2S credentials row.
- [2026_06_23_000001_create_zoom_webinars_table.php](/database/migrations/2026_06_23_000001_create_zoom_webinars_table.php) · [000002_create_zoom_webinar_attendances_table.php](/database/migrations/2026_06_23_000002_create_zoom_webinar_attendances_table.php) · [000003_add_zoom_to_event_registrations_table.php](/database/migrations/2026_06_23_000003_add_zoom_to_event_registrations_table.php) — webinars + attendance + the per-lead registrant columns.
- [2026_06_30_000001_add_source_to_event_registrations_table.php](/database/migrations/2026_06_30_000001_add_source_to_event_registrations_table.php) — `event_registrations.source` (`unsignedInteger`, default `SOURCE_LANDING`, indexed) → LANDING / ADMIN / WALK_IN · [2026_06_30_000002_add_match_method_to_zoom_webinar_attendances_table.php](/database/migrations/2026_06_30_000002_add_match_method_to_zoom_webinar_attendances_table.php) — `zoom_webinar_attendances.match_method` (`string(20)` nullable, indexed).
- [2026_06_25_000002_add_sync_error_to_zoom_webinars_table.php](/database/migrations/2026_06_25_000002_add_sync_error_to_zoom_webinars_table.php) (CREATING/FAILED reason) · [2026_06_26_000002_add_recording_to_zoom_webinars_table.php](/database/migrations/2026_06_26_000002_add_recording_to_zoom_webinars_table.php) (`recording_url` + `recording_synced_at` for the replay link).
- [2026_07_17_000001_add_discovery_to_zoom_webinars_table.php](/database/migrations/2026_07_17_000001_add_discovery_to_zoom_webinars_table.php) — `event_id` becomes **nullable** (a discovered webinar has no session) + `source` (`unsignedInteger`, default `SOURCE_APP`, indexed) → APP / DISCOVERED. Deliberately **no** unique index on `zoom_webinar_uuid` — soft-deleted rows keep occupying one, so occurrence uniqueness lives in `upsertFromDiscovery()` instead.
- [2026_06_23_000006_drop_admin_zoom_credentials_table.php](/database/migrations/2026_06_23_000006_drop_admin_zoom_credentials_table.php) — drops the legacy per-admin OAuth token store.
- `zoom_meetings` / `zoom_recordings` (created earlier; reused by the migrated meetings + recordings flows).
- [2026_07_23_100003_add_media_id_and_archive_state_to_zoom_recordings_table.php](/database/migrations/2026_07_23_100003_add_media_id_and_archive_state_to_zoom_recordings_table.php) — adds `media_id` (`unsignedBigInteger` nullable, indexed, no schema FK — mirrors `call_recordings.media_id`), `archive_status` (`unsignedInteger` default `ARCHIVE_NONE`, indexed), `archived_at` (`dateTime` nullable) to `zoom_recordings` for the durable GCS archive.
- [2026_06_28_000001_add_transcript_fallback_to_zoom_meetings.php](/database/migrations/2026_06_28_000001_add_transcript_fallback_to_zoom_meetings.php) — adds `transcript_source` (`string(10)` nullable, indexed) and `transcript_status` (`unsignedInteger` default `TRANSCRIPT_NONE`, indexed) to `zoom_meetings` for the AI transcription fallback lifecycle.
- [2026_07_15_000001_add_zoom_webinar_id_to_zoom_meetings_table.php](/database/migrations/2026_07_15_000001_add_zoom_webinar_id_to_zoom_meetings_table.php) — adds `zoom_webinar_id` (`unsignedBigInteger` nullable, **unique**) to `zoom_meetings` — the webinar-recording discriminator/FK (Eloquent-level only) and the DB-level idempotency guard for `syncWebinar()`'s race (P2).
- [2026_07_28_100001_add_zoom_sync_and_join_token_to_event_registrations.php](/database/migrations/2026_07_28_100001_add_zoom_sync_and_join_token_to_event_registrations.php) — two additions to `event_registrations`, both serving the per-lead join link. **The push sweep's stop conditions:** `zoom_sync_attempts` (`unsignedSmallInteger` default `0`) + `zoom_sync_attempted_at` + `zoom_sync_error` — a lead with no email or one Zoom keeps 4xx-ing used to leave `zoom_registrant_id` NULL with no record of why, which `zoom:push-registrants` would then re-POST every five minutes forever. The counter stops the rows Zoom refuses at `EventRegistration::MAX_ZOOM_SYNC_ATTEMPTS`; the ceiling-free skips are held instead by `pendingZoomPush()` (data gap → until the datum arrives, `daily_limit` → until 00:00 UTC), and `zoom_sync_error` is what the Registrations tab's "Not registered on Zoom" chip reads. **The short link:** `join_token` (`string(32)` nullable, **unique** — it IS the lookup key; minted lazily on the first send that needs it) plus its `join_click_count` (`unsignedInteger` default `0`) / `join_clicked_at` tally.

**Routes**
- [routes/web.php](/routes/web.php) — `manage.integrations.zoom.{index,users,update,test}`, `manage.events.webinar.{store,retry,responses.sync,chat.store}` (all under the `{id}/webinar` prefix, `permission:MANAGE_EVENTS`; the webinar is bound to the session, so there is **no** standalone `update`/`cancel` — a session edit/cancel re-syncs or tears it down via `EventsController`), `manage.events.attendance.export` (no-show export — declared **before** the `events/{id}` show route per §14), `manage.leads.zoom-meetings.store`, `manage.leads.zoom-recordings.sync`, `manage.zoom-meetings.{update,cancel,destroy,start}`, `manage.zoom.recordings.*`, `manage.people.admins.zoom-recordings.sync`, `manage.zoom-recordings.stream` — all behind `auth` + `admin` only (the Zoom action is gated in the controller by `isAccountUser`; there is **no** `zoom` middleware).
- [routes/main.php](/routes/main.php) — public `webhooks.zoom.handle` (signature-verified) · the public **per-lead join short link** `GET zoom/{token}` → `Main\ZoomJoinController@show`, named **`session.join`** — **no middleware, no `signed`**: the `[A-Za-z0-9]{16}` token (case-sensitive, `Str::random` is mixed case) is the only credential. Declared **above** the `{slug}` / `{funnel}/{slot}` funnel catch-alls, and `zoom` is a reserved funnel slug, so a funnel can never shadow it.

**Post-meeting brief (the delivery step).** The moment `AnalyzeZoomMeeting` saves a
first-run analysis (never on a forced re-run), it dispatches
`App\Jobs\Zoom\SendZoomMeetingBrief`: a WhatsApp message (the agent's own phone,
through the connected channel) + an email (`emails.zoom.meeting-brief`, via the
provider-agnostic `EmailSender`) carrying the topic, AI score, top action items,
suggested follow-up date and a portal link. Delivery only — it never calls an AI
provider. Both legs are independent and fail-soft (logged, never failing the
analysis pipeline), and recipients are gated on the SALES EXECUTION role flag.
Every outcome lands in the **`zoom_meeting_briefs` ledger** (`Src\Zoom\ZoomMeetingBrief`,
one row per meeting; per-leg STATUS sent/skipped/failed + SKIP reason, written via
`ZoomMeetingRepository::recordBrief`), and the Recordings list shows it as a
**Brief column**: a WhatsApp badge + an email badge, tooltips carrying the skip
reason; the WhatsApp badge reads its LIVE delivery state (Queued → Sent →
Delivered → Read / Failed) off the outbound `whatsapp_messages` row the ledger
points at. "—" = the meeting predates the feature or isn't analyzed yet.

### AI Agent page (pipeline health)

**Zoom → AI Agent** (`/manage/zoom/ai-agent`, route `manage.zoom.ai-agent.index`,
`permission:VIEW_ZOOM`) is the health page of the AI pipeline **itself** — transcribe →
analyze → brief — where the Dashboard measures the human agents the pipeline serves. It is
read-only and built entirely from the three ledgers every step already writes: `zoom_meetings`
(transcript + analysis lifecycles), `ai_requests` (every provider call, filtered on
`subject_type = Src\Zoom\ZoomMeeting` — volume, success rate, cost, tokens, latency), and
`zoom_meeting_briefs` (the delivery legs). Sections, each linking into the filtered Recordings
list: **KPI tiles** (period AI calls / success rate / failed / cost / tokens / avg latency) →
the **pipeline funnel** (meetings → transcribed, split Zoom vs AI fallback → analyzed →
briefed, with "no ledger" = analyzed before brief delivery shipped or via a forced re-run) →
the **AI-call trend** (the shared `AgentTrendChart`, stacked by prompt key) + a
**needs-attention card** (ready-to-analyze, stuck >1 h at PROCESSING, failed — its
ready-to-analyze count deliberately matches its link's filter, `transcript_status=DONE` +
`ai_status=PENDING`, so the count and the click-through always agree) → the **latest briefs
ledger** (per-leg badges + spelled-out skip reasons, labels/colors resolved server-side per
GUIDELINES §5).

- [app/Http/Controllers/Manage/Zoom/ZoomAiAgentController.php](/app/Http/Controllers/Manage/Zoom/ZoomAiAgentController.php) — the read-only aggregation (tiles / funnel / trend / briefRows / hygiene / `clientBriefing`).
- [resources/js/Pages/Manage/Zoom/AiAgent/Index.vue](/resources/js/Pages/Manage/Zoom/AiAgent/Index.vue) — the page (mounts the `zoom` SectionTabs strip).
- [resources/js/Components/AiAgent/ClientBriefing.vue](/resources/js/Components/AiAgent/ClientBriefing.vue) — the chat's opening client briefing (waiting customers + top enquiries); rendered by the shared panel only when a module passes a `briefing`.
- [src/Zoom/ZoomMeetingBrief.php](/src/Zoom/ZoomMeetingBrief.php) — gained the `admin()` relation the ledger table reads.

**The page speaks manager, not engineer.** The default view is written for a non-technical
sales manager: a plain-outcome tile row (`plainTiles()` — meetings joined / written up /
briefings / follow-ups open / cost), ability cards phrased in the first person ("I debrief
your team after meetings", never "auto-analyze sweep"), and a first-person hero narrative.
Everything pipeline-shaped — provider KPIs, the funnel, the AI-call trend, the ledgers, the
needs-attention card — folds behind a **"Show technical details"** toggle. The chat snapshot
additionally carries **`team_performance`** (`teamRows()` — per-salesperson meetings, talk
minutes, avg AI score, open follow-ups), so "who on my team is doing well?" gets a grounded
answer with real names; the `zoom_agent_chat` persona is instructed to speak plain business
language (no pipeline vocabulary), judge fairly (few scored meetings = "too early to tell"),
and end with one concrete next action.

**"What you can do next" + one-click switches.** The page's first card after the hero is an
ordered, live-computed checklist (`nextSteps()`) — subscribe to alerts / clear the backlog /
name the mystery meetings / confirm pending matches / chase open follow-ups — each row carrying
a REAL action: a link, an in-page anchor, or a **one-click Turn on** button. The buttons work
because the agent's switches moved from `.env`-only to a **DB-backed singleton**
([`Src\Zoom\ZoomAgentSetting`](/src/Zoom/ZoomAgentSetting.php), `zoom_agent_settings` — each
flag NULLABLE, null = fall back to env) resolved by
[`ZoomAgentSettingProvider::applyOverrides()`](/src/Zoom/Services/ZoomAgentSettingProvider.php)
from `AppServiceProvider::boot()` — the exact `MessagingCredentialProvider` pattern, so every
existing reader (schedules, commands, this controller) keeps reading `config('services.zoom.*')`
unchanged. Writes go through `ZoomAgentSettingRepository::save()` via `POST
manage/zoom/ai-agent/settings` (`@toggle`, `ToggleAgentSettingRequest`, **MANAGE_ZOOM**); the
off-cards in the capability grid carry the same Turn on button (or a Subscribe link for alerts,
whose "switch" is a notify subscription, not a flag). Nav: the **AI Agent tab sits second**,
between Dashboard and Action Items — it is the "what is the AI doing for me" front door.

**The robot persona, visual replies, and the work diary.** Three layers make the agent feel
alive rather than reported-on: (1) **[Components/AiAgent/RobotAvatar.vue](/resources/js/Components/AiAgent/RobotAvatar.vue)**
*(shared with the [Phone Call AI Agent](/docs/modules_handbook/manage/call-history/readMe.md), as is the chat panel)* —
a hand-drawn SVG robot (pure CSS keyframes, no libraries) that floats, blinks and pulses its
antenna, with a `thinking` mood (eyes dart, antenna races) shown while a chat reply is pending;
the hero narrative and chat replies **type themselves out**, tiles count up via the shared
**[Components/AnimatedNumber.vue](/resources/js/Components/AnimatedNumber.vue)**, and cards
stagger in — every animation is disabled under `prefers-reduced-motion`. (2) **Chat replies are
visual**: the `zoom_agent_chat` prompt returns structured JSON `{text, bars, table}` (JSON
mode); the controller validates/caps the shapes (8 bar items, 5×10 table) and the panel renders
an animated horizontal-bar comparison or a compact table inside the bubble once the text
finishes typing — falling back to plain text if the model answers unstructured. (3) **"My
activity"** — the agent's work diary (`activity()`): *Just done* harvests the timestamped
ledgers (`ai_requests` by prompt key, `zoom_meeting_briefs` sent/held-back, pending lead
matches, `notify_deliveries` alerts) into first-person lines with times; *Coming up* lists the
next firings computed from the REAL schedule state (next 10-min prep pass, next hourly health
check, tonight's 03:00 fetch), each carrying an Off tag when its switch is off — planned work
is never invented. The top 8 diary lines also join the chat snapshot (`recent_activity`).

**Talking to the agent (the chat panel).** The page is built to *feel* like an agent, not a
report: a persona hero (Bot avatar + Active pulse + a **first-person narrative** composed
deterministically from the live data in `narrative()` — no AI call, so it can never
hallucinate its own report) and a sticky right-column **chat panel**
([Components/AiAgent/AgentChatPanel.vue](/resources/js/Components/AiAgent/AgentChatPanel.vue)).
Chat replies come from `POST manage/zoom/ai-agent/chat` (`ZoomAiAgentController@chat`,
validated by `AgentChatRequest` — message + a capped replayed history) — a **non-Inertia JSON
endpoint consumed by fetch + XSRF header, the AI Conversations pattern**. Each turn re-builds
the SAME live snapshot the page renders (tiles / funnel / follow-through / capabilities /
hygiene / recent briefs / pending suggestions) and sends it as the grounding context for the
registered **`zoom_agent_chat`** prompt (first-person persona, snapshot-only numbers, concise +
actionable, admits its own gaps). Runs on the global admin key like
every internal AI feature; every turn is logged to `ai_requests` under its prompt key.

**The opening client briefing — the panel never opens empty.** A chat box that greets the
reader with "ask me anything" makes them invent a question before it will help; the one
question they always have — *who do I help next?* — is answerable before they type. So the
agent's FIRST bubble is `clientBriefing()` (`clientBriefing` prop →
[Components/AiAgent/ClientBriefing.vue](/resources/js/Components/AiAgent/ClientBriefing.vue),
revealed once the headline finishes typing), composed from the stored analyses with **no AI
call** — instant, free, and unable to invent a customer:

- **`follow_ups`** — the customers still owed a next step (the Action Items queue's own
  `next_steps`-present + `followed_up_at IS NULL` definition, kept character-identical so the
  two surfaces can never disagree), ranked by `briefingPriority()`: interest and buying stage
  dominate, waiting time accrues but is capped at 30 so an ancient row can't outrank a hot one,
  a genuine overdue date adds on top. Deliberately **not** period-scoped — an unkept promise
  gets *more* urgent with age, not less. Each row carries what we promised, the customer's own
  concerns, and three ways in: the name opens the shared **lead modal** (`useLeadModal`), a link
  opens the meeting write-up, and "Ask me how to handle it" hands that customer to the chat.
  `follow_up_date` is only trusted when it is on/after the meeting — the model has emitted dates
  *years* before the meeting they belong to, and those must never render as overdue promises.
- **`enquiries`** — what customers keep raising, from their own `customer.needs` / `concerns`
  bucketed by the `ENQUIRY_THEMES` keyword table (deterministic, first match wins; the catch-all
  "Other questions" is sorted last so it never takes a named theme's slot). Counted by
  **customer**, not by sentence, and every theme shows a **verbatim** quote so a mis-bucketed
  line is visible rather than hidden behind its label. Period-scoped — this one *is* "right now".

The same array is the first key of the chat snapshot (`clients_waiting`, 15 rows deep vs the 6
shown), so every name on screen can be asked about immediately and the answer comes from the
same rows. `suggestedQuestions()` leads with the customer questions for the same reason. The
`briefing` / `queueUrl` props on the shared panel are **optional** — Phone Call, F2f, Portal and
Messages pass neither and keep the plain greeting.

**Keeping the pipeline moving (watchdog + backlog sweep).** Two hourly scheduled commands make
the agent self-driving instead of click-driven:

- **`zoom:ai-watchdog`** ([WatchZoomAiPipeline](/app/Console/Commands/WatchZoomAiPipeline.php),
  always on, `--hours=2` / `--dry-run`) — **heals**: rows stuck at `TRANSCRIPT_PROCESSING` /
  `AI_PROCESSING` longer than the threshold are flipped to FAILED via
  `ZoomMeetingRepository::releaseStuckProcessing()` (a job that dies without its `failed()`
  hook leaves PROCESSING forever; nothing else resets it), which makes them retryable (the
  Retry buttons, the sweep below). **Alerts**: fires the **`zoom.ai_stalled`** notify event
  (registered in [config/notify.php](/config/notify.php), 6 h throttle, shared
  [`Notifier`](/docs/modules_handbook/shared/notify/readMe.md)) on released stuck work, a ≥50 %
  failure rate over the last 24 h of Zoom AI calls (min 5 settled), or — only when the sweep is
  enabled — a backlog with zero AI calls in 24 h (a dead scheduler/queue, the silent failure mode).
- **`zoom:auto-analyze`** ([AutoAnalyzeZoomRecordings](/app/Console/Commands/AutoAnalyzeZoomRecordings.php),
  `--limit=` / `--dry-run`) — the **backlog sweep**: queues transcribe + analyze through the same
  shared `queuePendingProcessing()` recipe as the dashboard's batch button, capped per run
  (`ZOOM_AUTO_ANALYZE_SWEEP_CAP`, default 10). Gated **OFF** by default
  (`ZOOM_AUTO_ANALYZE_SWEEP`) because the branch matrix retries FAILED rows and **no per-row
  attempt ceiling exists** (unlike the registrant push's `zoom_sync_attempts`) — a permanently
  failing row re-enters every sweep, bounded only by the cap, so enabling is a deliberate spend
  decision watched by the failure-spike alert. Distinct from `ZOOM_AUTO_ANALYZE_NEW`, which only
  appends `--analyze` to the daily 03:00 sync (new rows, that moment only, no retry). The
  watchdog is scheduled before the sweep so a released row is retried in the same hour.

**The agent's smarter half (prep briefs · follow-through · memory · lead matching).** Four more
capabilities, all surfaced live on the AI Agent page's **"What the agent is doing"** grid (7
cards: sweep / watchdog / alerts / post briefs / prep briefs / memory / lead matching, each
with an On/Off badge + a proof-of-work counter):

- **Pre-meeting prep brief** — `zoom:send-prep-briefs` ([SendZoomPrepBriefs](/app/Console/Commands/SendZoomPrepBriefs.php),
  every 10 min, ON by default via `ZOOM_PREP_BRIEFS`, window `ZOOM_PREP_BRIEF_WINDOW` = 60 min)
  queues [SendZoomMeetingPrepBrief](/app/Jobs/Zoom/SendZoomMeetingPrepBrief.php) for upcoming
  **lead** meetings starting inside the window: the mirror of the post-meeting brief (same
  WhatsApp + email legs, same SALES EXECUTION gate, composition only — no AI call) carrying the
  last meeting's summary, score and still-open action items. Ledgered as **`KIND_PREP`** in
  `zoom_meeting_briefs` (migration adds `kind`; the unique moves to `(zoom_meeting_id, kind)`;
  `ZoomMeeting::brief()` now filters KIND_POST, `prepBrief()` KIND_PREP) — the ledger row is the
  idempotency key, so the cadence can never re-ping a meeting.
- **Action-item follow-through** — the funnel gains a fifth step, **Followed up** (closure
  count, open count, closure rate, avg days-to-close), computed from `followed_up_at` against
  meetings whose analysis produced action items. No new writes — it measures the existing
  Action Items flow.
- **Cross-meeting memory** — `AnalyzeZoomMeeting::priorMeetingContext()` prepends the lead's
  up-to-3 most recent prior analyzed meetings (date + summary + open next steps) as a clearly
  fenced context preamble, so a repeat meeting is analysed as a progression, not in isolation.
  No schema change; the page counts "enriched" analyses as those whose lead already had an
  earlier analyzed meeting.
- **AI lead matching** — `zoom:suggest-leads` ([SuggestZoomLeads](/app/Console/Commands/SuggestZoomLeads.php),
  hourly, **OFF** unless `ZOOM_LEAD_SUGGEST`, capped `ZOOM_LEAD_SUGGEST_CAP` = 10) queues
  [SuggestZoomMeetingLead](/app/Jobs/Ai/SuggestZoomMeetingLead.php) per unmatched ended meeting
  with a transcript: the **`zoom_lead_match`** prompt (registered in `config/ai_prompts.php` +
  `resources/prompts/zoom_lead_match.md`) extracts the customer's self-revealed names / phones /
  emails, then plain DB matching resolves email-exact > phone-tail > **unique** full name (an
  ambiguous name is no match — the WebinarAttendeeMatcher discipline). The proposal lands on
  `zoom_meetings.suggested_lead_id` / `suggested_lead_reason` / `lead_suggested_at` (set even
  when nothing was found, so a transcript is never re-mined) and is **never auto-linked**: the
  AI Agent page lists pending suggestions with **Confirm** (`POST
  …/recordings/{id}/suggested-lead/confirm` → `ZoomMeetingRepository::confirmSuggestedLead`,
  the same cascade as a manual link) and **Dismiss** (`DELETE …/{id}/suggested-lead`) actions,
  both `MANAGE_ZOOM` + lead-visibility-gated.
