# Zoom Webinar Chat (Manage)

**Portal:** Manage · **Routes:** `manage.events.webinar.chat.store` · **Surfaced on:** the **session detail** page → **Engagement** tab → a **Chat** sub-tab (alongside Lead Engagement / Polls / Q&A).

## 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 }).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.

The route is `POST /manage/events/{id}/webinar/chat` (name `manage.events.webinar.chat.store`, gated by `permission:MANAGE_EVENTS`), declared inside the `{id}/webinar` group in [routes/web.php](/routes/web.php) — `{id}` is the **session (Event) uuid**, not the webinar's. It is the only route in the subsystem (no index / show / destroy / export).

### 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). `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 reads the file, calls [`ChatFileParser::parse()`](/src/Zoom/Support/ChatFileParser.php), hands the result to [`ZoomWebinarChatRepository::syncFromUpload()`](/src/Zoom/Repositories/ZoomWebinarChatRepository.php), and flashes `"Imported {total} messages, {matched} matched to leads."`. On success the Vue doesn't navigate — it calls `router.reload({ only: ['engagement'] })` so the message log and the updated Lead-Engagement chat counts refresh in place.

> The upload endpoint never checks whether the webinar has ended — but the whole Engagement tab (chat included) is only *rendered* once the webinar `is_ended` (see *Read path*), so a message uploaded to a not-yet-ended webinar imports fine and is simply invisible until then.

### Parsing (`ChatFileParser`)
[`ChatFileParser`](/src/Zoom/Support/ChatFileParser.php) is pure and static — no DB, no normalization. It splits the whole file on `/\r\n|\r|\n/`, skips blank lines, and tries two header regexes per line, current format first:
- **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 `:`.

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()` turns the verbatim parsed value into a Carbon datetime: an empty raw value → `null`; 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). Re-uploading a **truncated** export silently destroys the fuller previous import; uploading a file for the **wrong session** wipes that session's real chat; chat row ids churn on every upload. There is also no unmatched-line reporting: a malformed export whose headers match neither regex imports as a handful of huge messages, or as zero messages, with a cheerful `"Imported 0 messages, 0 matched to leads."` success flash. 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` unless the webinar `is_ended` (Zoom reports are past-only). When the webinar ended but no poll/Q&A has synced, it still returns a real `summary` + `chat` (a `has_data: false` shape) — so a **chat-only webinar stays fully usable**; `has_data` is a hint, not a gate.

- [`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 a standalone webinar-recording surface passes `null`), 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 just render `<WebinarEngagementPanel :engagement>`, 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, 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); two header regexes (current dated / legacy time-only), multi-line body collapse, verbatim `sent_at`, 0-based message `line_index`.
- [src/Zoom/Repositories/ZoomWebinarChatRepository.php](/src/Zoom/Repositories/ZoomWebinarChatRepository.php) — the only write path: `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 → parse → repository → flash → `back()`).
- [app/Http/Requests/Manage/Events/Webinar/UploadChatRequest.php](/app/Http/Requests/Manage/Events/Webinar/UploadChatRequest.php) — `file => required|file|mimes:txt|max:10240`; `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 until `is_ended`), `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**); rendered by both the Events Engagement tab and the Recordings `PollQnaTab`.
- [Components/WebinarEngagement/ChatResults.vue](/resources/js/Components/WebinarEngagement/ChatResults.vue) — the chat UI: upload dropzone / stat cards / Replace + Re-upload / All-messages ⇄ By-sender `DataTable` toggle; posts to `chat.upload_url` then `router.reload({ only: ['engagement'] })`.

**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`. The only chat route.

**Tests**
- [tests/Feature/Zoom/ZoomWebinarChatTest.php](/tests/Feature/Zoom/ZoomWebinarChatTest.php) — 10 tests in four groups: parser (legacy + current multi-line), roster matching (attendance, registration-only, ambiguous-left-unmatched, absent-left-unmatched, honorific-prefixed), idempotency (re-sync produces no dupes, a legitimately repeated message is not dropped), and an integration test asserting the `engagement.chat` counts + the matched lead's summary chat count / score.
