# Zoom Webinar Chat (Manage)

**Portal:** Manage · **Routes:** `manage.events.webinar.chat.store` (session surface) + `manage.zoom.recordings.chat` (recording surface) · **Surfaced on:** the **session detail** page → **Engagement** tab → a **Chat** sub-tab (alongside Lead Engagement / Polls / Q&A, and Live Q&A when present), and the **Zoom Recordings** detail modal → **Poll & Q&A** tab of a webinar row.

## What it does
Captures a Zoom **webinar's in-meeting chat** and attaches each message to a lead where it can. It exists because **Zoom exposes no chat report endpoint** — unlike attendance (webhooks) and polls / Q&A (the report endpoints [`SyncWebinarResponses`](/app/Jobs/Zoom/SyncWebinarResponses.php) pulls), there is nothing to fetch. So this is the Zoom module's **only manual-ingress pipeline**: an admin exports the chat as a plain `.txt` from the Zoom client and uploads it on the session's Engagement → Chat sub-tab. There is **no job, no artisan command, no webhook and no scheduler entry** — nothing is ever fetched automatically, and if nobody uploads, the session's chat stays empty forever.

Once uploaded, each message is parsed into its own row, its sender is best-effort matched to a lead, and the results surface two ways: the **Chat** sub-tab itself (a full message log + a per-sender view + total / matched / unique-sender counts), and a per-lead **chat count** merged into the Engagement summary's score (`score = 2×polls + 3×Q&A + 1×chats`).

> **A chat display name is not an identity key.** Matching here is deliberately weaker and narrower than the attendance/response matcher — see *Sender → lead matching* below. It favours leaving a message unmatched over attaching it to the wrong lead.

## How it works

### Ingress — a manual `.txt` upload
Zoom has no chat API, so the admin saves the webinar chat as a `.txt` from the Zoom client and uploads it from [`ChatResults.vue`](/resources/js/Components/WebinarEngagement/ChatResults.vue) (relocated to the shared `Components/WebinarEngagement/` folder by PR #75). The Vue posts the file (Inertia `useForm({ file: null, force_replace: false }).post(props.chat.upload_url, { forceFormData: true, preserveScroll: true })`) to the prop-supplied `upload_url` — the page never builds the URL itself. The same `submit()` serves both the empty-state **"Add Meeting Chat File"** button and the populated-state **"Re-upload"** button (Re-upload asks through a `ConfirmModal` first; a `chat_replace` validation error turns into a **"Replace anyway"** button that resubmits with `force_replace = true`).

There are **two** upload routes, both declared in [routes/web.php](/routes/web.php), both ending in the same shared trait (below):
- `POST /manage/events/{id}/webinar/chat` (name `manage.events.webinar.chat.store`, `permission:MANAGE_EVENTS`), inside the `{id}/webinar` group — `{id}` is the **session (Event) uuid**, not the webinar's.
- `POST /manage/zoom/recordings/{id}/chat` (name `manage.zoom.recordings.chat`, `permission:MANAGE_ZOOM`) → `RecordingsController@uploadChat` — `{id}` is the **`ZoomMeeting` uuid** of a webinar recording row. It loads the row `withWebinars()`, checks `LeadVisibility::allowsLeadOrOwner` (403), rejects a non-webinar row with a flash error, resolves `$meeting->webinar` — or **creates an event-less one** via `ZoomWebinarRepository::firstOrCreateEventless()` and binds it with `ZoomMeetingRepository::linkWebinar()` — so a **standalone** webinar (no session) can take a chat upload too.

There is no index / show / destroy / export route.

### Validate → resolve → parse → persist
[`UploadChatRequest`](/app/Http/Requests/Manage/Events/Webinar/UploadChatRequest.php) validates `file => required|file|mimes:txt|max:10240` (a 10 MB cap) and `force_replace => nullable|boolean`. `authorize()` returns `true` — real authorization is the route's `permission:MANAGE_EVENTS` middleware. `mimes:txt` sniffs the file's actual content rather than trusting the browser Content-Type, so an odd mime string never rejects a genuine export.

[`WebinarChatController@store`](/app/Http/Controllers/Manage/Events/WebinarChatController.php) is thin. It resolves the **event** by uuid (`Event::with(['webinar'])->where('uuid', $id)->firstOrFail()`); if the session has no linked webinar it soft-guards with `flash()->warning('No Zoom webinar is linked to this session.')` + `back()` (not a 404). Otherwise it hands the file and `$request->boolean('force_replace')` to the shared **`importWebinarChat()`** in [`Concerns/ImportsWebinarChat.php`](/app/Http/Controllers/Concerns/ImportsWebinarChat.php) — the one recipe both upload routes use. It calls [`ChatFileParser::parse()`](/src/Zoom/Support/ChatFileParser.php) and, behind **three guards** (replace-all is destructive and Zoom has no chat report to restore from), [`ZoomWebinarChatRepository::syncFromUpload()`](/src/Zoom/Repositories/ZoomWebinarChatRepository.php):
1. **Unrecognised file** — 0 parsed messages → a warning flash, **nothing written**.
2. **Suspicious shrink** — the file holds fewer than `REPLACE_SHRINK_RATIO` (0.5) × the messages already stored → a `ValidationException` on `chat_replace` (the UI's "Replace anyway"), unless `force_replace`.
3. **Nobody matched** — the import is written, but flashed as a **warning** (wrong webinar's file, or attendance not synced yet) instead of a success.

Otherwise it flashes `"Imported {total} messages, {matched} matched to leads."`. On success the Vue doesn't navigate — it calls a full `router.reload({ preserveScroll, preserveState })` (deliberately **no** `only:` — on the Recordings surface the prop is `detail.engagement`, so `only: ['engagement']` refreshed nothing) so the message log and the updated Lead-Engagement chat counts refresh in place.

> The upload endpoint never checks whether the webinar has ended. On the session page the Engagement tab is shown whenever a webinar exists (see *Read path*). On the **Recordings** detail, `PollQnaTab` renders the engagement panel only when `engagement.has_data` is true — so a webinar recording with **no synced poll/Q&A shows no Chat sub-tab and no upload button there** (sync poll & Q&A first, or upload from the session page).

### Parsing (`ChatFileParser`)
[`ChatFileParser`](/src/Zoom/Support/ChatFileParser.php) is pure and static — no DB, no normalization. It strips a leading UTF-8 BOM, splits the whole file on `/\r\n|\r|\n/`, skips blank lines, and tries **three** header layouts per line, in this order:
- **Cloud recording (tab-separated)** — `HH:MM:SS<TAB>Sender:<TAB>message` — the chat file Zoom saves *with a cloud recording*. Its time is an **elapsed offset** from the recording start; the target defaults to `Everyone` and a `(Direct Message)` suffix is split out. A line shaped like `From X to Y` is skipped here and left to the legacy branch.
- **Current (dated + indented)** — `YYYY-MM-DD HH:MM:SS From "<Sender>" to <Target>:` — the sender **must be double-quoted**.
- **Legacy (single line)** — `HH:MM:SS From <Sender> to <Target>: <message>` — quotes optional, but the sender may not contain `:`.

The current and legacy patterns are case-insensitive. Each row also carries `sent_at_format` (`datetime` / `clock` / `offset`) so the repository knows how to compose the time.

A header line flushes the previous message and starts a new one; any trailing text after the colon seeds the body (so exports that put the message on the header line work). A **non-header** line is `ltrim`'ed of tabs/spaces and appended to the current message body — this is how tab-indented multi-line messages collapse into a single row (joined with `"\n"`). `line_index` is the **0-based ordinal of the message** (count emitted so far), not the physical file line number. `sent_at` is returned **verbatim** as found — composing the legacy time-only value with a date is deliberately the repository's job.

### Sender → lead matching (`ZoomWebinarChatRepository`)
There is exactly **one** matching tier: an exact **normalized-name lookup against a roster built from this webinar alone**. [`buildRoster()`](/src/Zoom/Repositories/ZoomWebinarChatRepository.php) unions two sources, both keyed by [`WebinarAttendeeMatcher::normalizeName()`](/src/Zoom/Support/WebinarAttendeeMatcher.php) (the *same* honorific-stripping rule attendance and responses use — that is why the method is `public static`):
- **Attendances** — `ZoomWebinarAttendance` rows for this webinar that already carry a `lead_id` (resolved during reconcile by the strong registrant/email tiers), keyed on the Zoom display name captured at join.
- **Event registrations** — this event's `event_registrations` with a `lead_id`, keyed on the lead's `profile->full_name` (a thin supplement for registrants with no attendance row).

A name resolving to **more than one distinct lead** is omitted from the resolved map — never guessed — so absent and ambiguous are indistinguishable to the caller: both mean unmatched. [`prepareRow()`](/src/Zoom/Repositories/ZoomWebinarChatRepository.php) then normalizes each sender, looks it up, and stamps `match_method` = `METHOD_NAME` when matched, else `METHOD_UNMATCHED`.

> **Honest limits.** Chat calls **only** `normalizeName()` — it never calls the full `match()`, so it skips *both* of `match()`'s safety filters: the `PLACEHOLDER_NAMES` list (guest / host / iPhone / anonymous / …) and the ≥2-token `isSafeToNameMatch()` rule. A chat line from "Guest" or a lone "wong" *will* match if that exact normalized name sits unambiguously in the roster. The safety comes not from name hygiene but from **roster scope** — a tiny, already-vetted set (this webinar's matched attendances ∪ this event's registrations), never the funnel's leads and never the global lead DB. In practice `match_method` is therefore always `METHOD_NAME` or `METHOD_UNMATCHED`; the `METHOD_REGISTRANT` / `METHOD_EMAIL` constants on the model are aliases of `ZoomWebinarAttendance`'s, kept only so the two tables share one taxonomy.

`composeSentAt()` (given the value and its `sent_at_format`) turns the verbatim parsed value into a Carbon datetime: an empty raw value → `null`; an `offset` (cloud-recording file) → `webinar->start_time` + the elapsed seconds (`null` without a `start_time`); a legacy time-only `HH:MM:SS` → composed with `webinar->start_time`'s date (so a past-midnight webinar mis-dates, and a webinar with no `start_time` yields `null`); otherwise `Carbon::parse` inside a try/catch that degrades any `\Throwable` to `null` rather than blowing up the import.

### Persistence — replace-all
[`syncFromUpload()`](/src/Zoom/Repositories/ZoomWebinarChatRepository.php) builds the roster and prepares every row **before** the transaction (GUIDELINES §2). The `DB::transaction` block contains only a `ZoomWebinarChat::where('zoom_webinar_id', $webinar->id)->delete()` followed by a per-row `ZoomWebinarChat::create()` loop and a `Log::info`. It returns `['total' => …, 'matched' => …]` computed from the prepared rows for the flash summary.

Idempotency is **replace-all**, not upsert — the uploaded file is the source of truth for that webinar's chat, so a re-upload fully supersedes the previous one. The unique index `zwc_webinar_message_hash_unique` on `(zoom_webinar_id, message_hash)` only guards accidental intra-parse duplicates; because `line_index` is baked into `message_hash`, two genuinely identical messages (same sender, same text, same second) hash differently and both survive.

> **Consequences of replace-all** — there is no merge, no versioning, no soft delete and no audit trail (child table: no `uuid`, no blame columns). The guards in `importWebinarChat()` now stop the two commonest accidents — a file that parses to nothing is refused, and a **truncated** export (fewer than half the stored messages) needs an explicit "Replace anyway" — but a **wrong-session** file of a believable size still wipes that session's real chat (it only earns a "none matched" warning), and chat row ids churn on every upload. There is no per-line unmatched reporting: a malformed export whose headers match no layout can still import as a handful of huge messages. And `target` / `sender_name` are silently `mb_substr`-truncated (30 / 191 chars) *after* the hash is computed.

### Read path
[`EventsController@show`](/app/Http/Controllers/Manage/Events/EventsController.php) attaches the `engagement` prop via a **thin `buildEngagementProp` wrapper that delegates to** [`WebinarEngagementBuilder::build()`](/src/Zoom/Services/WebinarEngagementBuilder.php) — the shared payload builder PR #75 extracted (also used by the Zoom Recordings detail page). It returns `null` **only when there is no webinar at all** — the tab is available as soon as a webinar exists, because the Live Q&A console is most useful *during* the session. When no poll/Q&A has synced, it still returns a real `summary` + `chat` (a `has_data: false` shape) — so on the **session page** a chat-only webinar stays fully usable; `has_data` is a hint there, not a gate (the Recordings `PollQnaTab` does gate on it — see above).

- [`buildChatProp()`](/src/Zoom/Services/WebinarEngagementBuilder.php) reads `ZoomWebinarChat::where('zoom_webinar_id', …)->with('lead:id,uuid')->orderBy('line_index')`, returning `total`, `matched` (rows with a `lead_id`), `unique_senders` (distinct `sender_key` — every unmatched sender, host included, counts as one), the `upload_url` (**passed in** as the builder's surface-agnostic `$chatUploadUrl` argument — `EventsController` passes `'/manage/events/' . $event->uuid . '/webinar/chat'`, while `ZoomRecordingDetailBuilder` passes `route('manage.zoom.recordings.chat', $meeting->uuid)` for **every** webinar row, Event-linked or standalone), a `messages[]` log, and a `by_sender[]` grouping (by `sender_key`, sorted by message count desc — a sender maps to a single lead by construction, so `lead_uuid` is safely taken from the group's first row).
- The engagement summary's per-lead **chat count** is a final merge pass in [`buildSummaryRows()`](/src/Zoom/Services/WebinarEngagementBuilder.php): it plucks `COUNT(*)` per `lead_id`, and only **assigns onto an existing `lead:{id}` row** (seeded from responses or attendance) before recomputing `score = 2×polls + 3×Q&A + 1×chats` — chat never synthesizes a summary row. So a lead matched *only* via the registration roster branch (registered + chatted, but attendance never reconciled to them) shows in the Chat sub-tab yet gets no Lead-Engagement row.

### UI surface
The Chat section is a nested [`ShowTabs`](/resources/js/Components/ShowTabs.vue) sub-tab inside the shared [`WebinarEngagementPanel.vue`](/resources/js/Components/WebinarEngagement/WebinarEngagementPanel.vue) (`{ key: 'chat', label: 'Chat', count: engagement.chat.total }`, body via `#tab-chat` → `ChatResults`), lazy-mounted only when active. Since PR #75 that panel is **shared**: [`EngagementTab.vue`](/resources/js/Pages/Manage/Events/Partials/Tabs/EngagementTab.vue) (the session's Engagement tab) and the Zoom Recordings detail's `PollQnaTab` both render `<WebinarEngagementPanel :engagement>` (the session page also passes its live questions, which adds a **Live Q&A** sub-tab — first while the session is live, last after), so the nested `ShowTabs` now lives in the panel, not in `EngagementTab`. [`ChatResults.vue`](/resources/js/Components/WebinarEngagement/ChatResults.vue) shows the upload dropzone when empty, and when populated shows 3 stat cards (Total / Matched to leads / Unique senders), a **Replace file** + **Re-upload** form (Re-upload confirms first; a shrinking file surfaces "Replace anyway"), and an **All messages** / **By sender** segmented toggle over two `DataTable`s. Matched senders link to `/manage/leads/{lead_uuid}`; unmatched render italic grey. Times are formatted in `usePage().props.userTimezone || 'Asia/Kuala_Lumpur'`; `sent_at` of `null` renders as `—`. Rows in the "all" view synthesize a `_key` from position because messages carry no id from the backend.

## Data model

Table **`zoom_webinar_chats`** (migration [2026_07_14_000002_create_zoom_webinar_chats_table.php](/database/migrations/2026_07_14_000002_create_zoom_webinar_chats_table.php); model [`Src\Zoom\ZoomWebinarChat`](/src/Zoom/ZoomWebinarChat.php)) is a **child table** per GUIDELINES §7 — no `uuid` (not route-bound), no blame columns, no soft deletes, no schema-level FK constraints; the model extends `Diver\Database\Eloquent\Model` (not `SoftDeleteModel`).

| Column | Type | Notes |
|--------|------|-------|
| `id` | `bigIncrements` | |
| `zoom_webinar_id` | `unsignedBigInteger`, indexed | FK-by-convention to `zoom_webinars.id` |
| `lead_id` | `unsignedBigInteger`, nullable, indexed | null when the sender isn't matched |
| `event_registration_id` | `unsignedBigInteger`, nullable | **not** indexed; written as provenance, currently never read back |
| `sender_name` | `string` (191) | display name from the file; `mb_substr`-truncated to 191 before insert |
| `sender_key` | `char(40)` | `sha1(normalizeName(sender_name))`; groups same-sender messages, powers `unique_senders` / `by_sender` |
| `target` | `string(30)`, nullable | e.g. `everyone` / `host` / a DM recipient; truncated to 30 before insert |
| `message` | `text` | not truncated |
| `sent_at` | `dateTime`, nullable | defensive — see `composeSentAt()` |
| `line_index` | `unsignedInteger` | 0-based ordinal of the message within the file |
| `match_method` | `string(30)` | stores `ZoomWebinarAttendance::METHOD_*` string values |
| `message_hash` | `char(40)` | `sha1(line_index\|sent_at\|sender_key\|message)` |
| timestamps | | `created_at` / `updated_at` |

Unique composite `zwc_webinar_message_hash_unique` on `(zoom_webinar_id, message_hash)`. `$fillable` = the 11 non-id/non-timestamp columns; `$casts` = `sent_at:datetime`, `line_index:integer`.

**Constants** — `ZoomWebinarChat` does not define its own match methods; `METHOD_REGISTRANT` / `METHOD_EMAIL` / `METHOD_NAME` / `METHOD_UNMATCHED` / `METHODS` are **aliases** of [`ZoomWebinarAttendance`](/src/Zoom/ZoomWebinarAttendance.php)'s, so both tables share one taxonomy (only `METHOD_NAME` / `METHOD_UNMATCHED` are ever written here). The repository holds two private width constants `MAX_TARGET_LENGTH = 30` / `MAX_SENDER_NAME_LENGTH = 191`; the engagement score weights are `WebinarEngagementBuilder` private consts `SCORE_WEIGHT_POLL = 2` / `SCORE_WEIGHT_QA = 3` / `SCORE_WEIGHT_CHAT = 1` (moved there from `EventsController` by PR #75).

## Related files

**Backend** (`src/Zoom/`, `app/`)
- [src/Zoom/Support/ChatFileParser.php](/src/Zoom/Support/ChatFileParser.php) — pure static `parse(string): array` (no I/O, no normalization); three header layouts (cloud-recording tab-separated / current dated / legacy time-only), BOM strip, multi-line body collapse, verbatim `sent_at` + `sent_at_format`, 0-based message `line_index`.
- [app/Http/Controllers/Concerns/ImportsWebinarChat.php](/app/Http/Controllers/Concerns/ImportsWebinarChat.php) — `importWebinarChat()`: the shared parse → guard (empty file / `REPLACE_SHRINK_RATIO` shrink / none-matched warning) → `syncFromUpload()` → flash recipe used by both upload routes.
- [src/Zoom/Repositories/ZoomWebinarChatRepository.php](/src/Zoom/Repositories/ZoomWebinarChatRepository.php) — the only write path for chat rows: `syncFromUpload()` (replace-all in one transaction) + `buildRoster()` / `prepareRow()` / `composeSentAt()`; `MAX_TARGET_LENGTH` / `MAX_SENDER_NAME_LENGTH`.
- [src/Zoom/ZoomWebinarChat.php](/src/Zoom/ZoomWebinarChat.php) — the model (`$table` = `zoom_webinar_chats`); `webinar()` / `lead()` / `eventRegistration()`; `METHOD_*` aliases of `ZoomWebinarAttendance`.
- [src/Zoom/Support/WebinarAttendeeMatcher.php](/src/Zoom/Support/WebinarAttendeeMatcher.php) — the chat path reuses **only** its `public static normalizeName()` (honorific-stripping incl. Malaysian titles); it never calls `match()`.
- [app/Http/Controllers/Manage/Events/WebinarChatController.php](/app/Http/Controllers/Manage/Events/WebinarChatController.php) — single thin `store()` (resolve event by uuid → `importWebinarChat()` → `back()`).
- [app/Http/Controllers/Manage/Zoom/RecordingsController.php](/app/Http/Controllers/Manage/Zoom/RecordingsController.php) — `uploadChat()`: the recording-surface upload (webinar rows only; mints + links an event-less `ZoomWebinar` when needed, then `importWebinarChat()`).
- [app/Http/Requests/Manage/Events/Webinar/UploadChatRequest.php](/app/Http/Requests/Manage/Events/Webinar/UploadChatRequest.php) — `file => required|file|mimes:txt|max:10240`, `force_replace => nullable|boolean`; `authorize()` = true (route middleware guards).
- [app/Http/Controllers/Manage/Events/EventsController.php](/app/Http/Controllers/Manage/Events/EventsController.php) — read path entry point: a thin `buildEngagementProp` wrapper that delegates to `WebinarEngagementBuilder::build()`, passing the session's chat-upload URL.
- [src/Zoom/Services/WebinarEngagementBuilder.php](/src/Zoom/Services/WebinarEngagementBuilder.php) — the shared engagement-payload builder (PR #75): `build()` (null only when there is no webinar), `buildChatProp` (the `chat` prop; `upload_url` passed in, surface-agnostic), and the `buildSummaryRows` chat-count merge (score recompute). The `SCORE_WEIGHT_*` consts live here. Consumed by BOTH the Events Engagement tab and the Zoom Recordings detail (`PollQnaTab`).

**Frontend** (`resources/js/`)
- [Pages/Manage/Events/Partials/Tabs/EngagementTab.vue](/resources/js/Pages/Manage/Events/Partials/Tabs/EngagementTab.vue) — the session's Engagement tab; since PR #75 it just renders `<WebinarEngagementPanel :engagement>` (the nested `ShowTabs` moved into the shared panel). `has_data` is an informational banner, not a gate.
- [Components/WebinarEngagement/WebinarEngagementPanel.vue](/resources/js/Components/WebinarEngagement/WebinarEngagementPanel.vue) — the shared panel hosting the nested `ShowTabs` (Lead Engagement / Polls / Q&A / **Chat**, + Live Q&A on the session page); rendered by both the Events Engagement tab and the Recordings `PollQnaTab` (the latter only when `has_data`).
- [Components/WebinarEngagement/ChatResults.vue](/resources/js/Components/WebinarEngagement/ChatResults.vue) — the chat UI: upload dropzone / stat cards / Replace + Re-upload (confirm modal, "Replace anyway" on a `chat_replace` error) / All-messages ⇄ By-sender `DataTable` toggle; posts to `chat.upload_url` then a full `router.reload({ preserveScroll, preserveState })`.

**Migration** (`database/migrations/`)
- [2026_07_14_000002_create_zoom_webinar_chats_table.php](/database/migrations/2026_07_14_000002_create_zoom_webinar_chats_table.php) — creates `zoom_webinar_chats` (child table; unique `zwc_webinar_message_hash_unique`).

**Route**
- [routes/web.php](/routes/web.php) — `POST /manage/events/{id}/webinar/chat` → `manage.events.webinar.chat.store`, inside the `{id}/webinar` group, `permission:MANAGE_EVENTS`; and `POST /manage/zoom/recordings/{id}/chat` → `manage.zoom.recordings.chat`, `permission:MANAGE_ZOOM` (`{id}` = the recording row's `ZoomMeeting` uuid).

**Tests**
- [tests/Feature/Zoom/ZoomWebinarChatTest.php](/tests/Feature/Zoom/ZoomWebinarChatTest.php) — 22 tests: parser (legacy, current multi-line, cloud-recording tab format, `From`/tab collisions, BOM, offsets), roster matching (attendance, registration-only, ambiguous / absent left unmatched, honorific-prefixed), idempotency, the upload guards (unrecognised file, shrink guard, `force_replace`, same-size replace), the Events upload endpoint, the recordings standalone upload, and an integration test asserting the `engagement.chat` counts + the matched lead's summary chat count / score.
