# Showroom F2F (Manage)

**Portal:** Manage · **Routes:** `manage.f2f.*` · **Nav:** Channel → **Showroom F2F** (one entry landing on `/manage/f2f/dashboard`, fronting the tab strip **Dashboard / AI Agent / Action Items / Recordings / Devices / Settings** — the AI Agent tab is the showroom twin of [Calls'](/docs/modules_handbook/manage/call-history/readMe.md): `Manage\F2f\F2fAiAgentController` → `Pages/Manage/F2f/AiAgent/Index.vue` on the shared `Components/AiAgent/` robot + chat, prompt **`f2f_agent_chat`**; automatic pipeline + human tagging, so the page is visibility + guidance (untagged recordings first) + a grounded chat with per-agent visit performance — rendered by [Components/SectionTabs.vue](/resources/js/Components/SectionTabs.vue), section `f2f`; Dashboard is the per-agent performance roll-up, Action Items the follow-up queue, Settings the AI analysis controls — the same performance layer shape as [Phone Call](/docs/modules_handbook/manage/call-history/readMe.md); the sidebar entry carries `prefixes: ['/manage/f2f']` so it stays lit on every tab) · **Label:** the recording's Admin owner is shown as **"Agent"** (outward label only — the code keys `admin_id`, the `sales` filter, `salesOptions` and the `salesperson` row prop are unchanged, the same way the module keeps its `F2f` code names behind the "Showroom F2F" nav label)

## What it does
Lists **face-to-face showroom conversation recordings** captured by the **smart badge** worn by an agent — ported from petaV2 `badge_recordings` into petav3's Manage/Inertia surface. It is the in-person sibling of [Phone Call](/docs/modules_handbook/manage/call-history/readMe.md): same idea (a recording tied to a customer + an agent, with transcript + AI analysis), different capture device. Admins browse/search/filter the recordings on **one page**, see each customer and agent, and a **Devices** button deep-links to the [Device registry](/docs/modules_handbook/manage/devices/readMe.md) scoped to badge devices.

> This is the **showroom list surface**. Like calls, an F2F recording links to its customer **only through a [Lead](/docs/modules_handbook/manage/leads/readMe.md)** (`lead_id`); the old WhatsApp-`contact_id` link was removed. `source_*` columns are kept as petaV2 lineage. Recordings arrive three ways: the historical `f2f:import` backfill, the live **yhy smart-badge webhook** ingestion, and a **manual admin upload** (the *Upload* button on the Showroom page) — the latter two feed the same transcription pipeline (see *Ingestion & processing pipeline* below).

## How it works
- **`F2fRecording`** is a key model (uuid + blame + soft delete) on `f2f_recordings`: `lead_id` (customer; NULL = untagged) + `admin_id` (**the agent — the single source of the Agent label**) + `device_sn`, the audio/transcript/AI columns, `SOURCES` (Smart Badge / Manual Upload) and `PIPELINE_STAGES` constants, plus the `source_recording_id` / `source_file_name` idempotency keys. `lead()` / `admin()` relations and the `untaggedForDevice()` predicate (the backfill's row set). There is no `status` lifecycle — tagged/untagged is derived from `lead_id`. (Phase C dropped the old free-text `salesperson_name` column — the code name stays.)
- **`ShowroomController@index`** takes filters through `F2fRecordingsQueryRequest` (search / sales / source / matched / date — the filter keys keep their code names), eager-loads `lead.user.profile` + `admin.user.profile`, transforms each row (customer name from the lead, **agent from the admin via `Admin::displayName()`** — the row prop is still `salesperson` —, `matched_lead`), and renders `Inertia::render('Manage/F2f/Showroom/Index')` with a paginator + `salesOptions` (admins that own recordings, keyed on `admin_id`) + `adminOptions` (all admins, for the upload/edit picker). The list columns are the **standardized recording-list set shared with [Phone Call](/docs/modules_handbook/manage/call-history/readMe.md)** — **Lead / Agent / Duration / Date / Stage** / actions. **Source, the badge serial, and the old Data icons are not columns** (Source stays a filter; `device_sn` is plumbing, still matched by free-text search and shown/edited in the detail + upload/edit forms). The **Lead** cell shows the matched customer or an **Unmatched** badge. The **Stage** column is the one shared pipeline vocabulary (`Src\Common\Support\RecordingStage` — Pending / Transcribing / Analyzing / Done / Failed), so both recording lists render the same five states. The page has a manual **Upload** panel and a **Devices** button (`/manage/devices?type[]=1`) — no per-agent performance table.
- **Matching grain:** a recording is "matched" when it has a `lead_id`; `unlinkedCount` counts recordings with none. Linking is by `lead_id` only — set by hand in the Edit modal (the badge carries no phone, so nothing can auto-match a customer).
- **Agent attribution is retroactive.** A live badge push resolves `admin_id` from the [Device registry](/docs/modules_handbook/manage/devices/readMe.md) at ingest time, so recordings that arrive **before** their badge is registered land `admin_id` NULL and show "—". Registering that badge later no longer strands them: **registering, restoring, or updating a `TYPE_BADGE` device backfills `admin_id` onto every `f2f_recordings` row with the same `device_sn` where `admin_id IS NULL`** — the write path lives in [Devices](/docs/modules_handbook/manage/devices/readMe.md) (`BackfillDeviceAttributionAction` → `F2fRecordingRepository::backfillAdminForDevice()`), and `php artisan f2f:backfill-attribution` sweeps the pre-existing backlog. It is a **fill, never an overwrite** — a row that already has an `admin_id` (auto-attributed at ingest, imported via `calls.sales_map`, or hand-set in the Edit modal) is untouched. Same principle as `lead_id` on re-import: automation never clobbers a human's tag. It also **refuses to guess**: a device write that changes the owner, serial, or type skips the backfill entirely, because the NULL rows accumulated while the badge was parked may be the *previous* agent's. ⚠️ **Known tension:** `bulkUpsert`'s `UPSERT_UPDATE` set includes `admin_id`, so re-running `f2f:import` re-resolves it from `calls.sales_map` (possibly back to NULL) for any row whose `source_recording_id` matches — on imported rows the backfill can appear to undo itself. Pre-existing; the importer and the registry disagree about who owns `admin_id`.
- **Manage CRUD surface (Show / Edit / Delete).** Each row has the §14 shared actions column — exactly three actions, standardized with [Phone Call](/docs/modules_handbook/manage/call-history/readMe.md). **Tagging a customer to a recording is done in the Edit modal** (the customer-lead `ComboBox` below), not a separate row action. **Show** opens a partial-reload, **read-only** detail modal (`F2fDetailModal`) that hosts the shared tabbed `RecordingDetail` — Overview (audio, metadata, petaV2 lineage, *Retry transcription*) / Transcript / AI analysis / Customer / Sales performance / Meeting report, the SAME component Zoom & Phone Call use; **Edit** opens the dedicated `F2fFormModal` (metadata only — agent, device SN, recorded date, and the customer-lead `ComboBox`, hydrated from the same `detail` prop) → `ShowroomController@update` → `F2fRecordingRepository::update()`; **Delete** confirms via `ConfirmModal` then **permanently** removes the recording. The audio/transcript/AI columns are pipeline-generated and never editable here.
- **The Sales performance tab also carries the HUMAN review layer**, directly below the unchanged AI block — a senior admin's own 0–10 score + optional comment on the recording (`ShowroomController::detail()` computes `Src\Conversation\Support\RecordingReviewGate::allows()` and passes it into `F2fRecordingPresenter::detail()` as REQUIRED, not optional, parameters, so a forgotten argument fatals instead of silently mis-rendering whose review is whose). The Dashboard's per-agent table gains a parallel **Review score** column (`human_avg_score` / `human_review_count` / `human_reviewed_recordings`, one grouped query — never a per-row walk) beside the existing AI score. Full detail: [Conversation Analysis → Human review](/docs/modules_handbook/shared/conversation-analysis/readMe.md#human-review-the-layer-below-the-ai-score).
- **Delete removes the audio but leaves a tombstone (soft delete).** `ShowroomController@destroy` first removes the stored audio from GCS (`MediaService::delete` — file **and** `Media` row) so the sensitive bytes are gone for good, then **`F2fRecordingRepository::softDelete()`** *soft*-deletes the row. It is deliberately not a hard delete: a hard delete frees the row's `source_file_name` / `source_recording_id`, and the badge resends relentlessly (a redelivered chunk or a re-run of `f2f:import` would then re-create it). The tombstone keeps those keys occupied, so re-ingest recognises it and skips. Both dedupe reads therefore look **`withTrashed()`**: `existsByYhyFileName()` (live Audio push) and `DownloadYhyRecording::alreadyIngested()` (the UploadLog/MergeAudio path) — a soft-deleted recording is counted as already-ingested and never re-downloaded. *(This is the F2F half of the "deleted recording comes back" fix; the Phone Call half is the same tombstone approach — see [Phone Call](/docs/modules_handbook/manage/call-history/readMe.md).)*
- **Re-import never resurrects a locally-deleted recording.** `F2fRecordingRepository::bulkUpsert()` syncs `deleted_at` from petaV2, so it guards re-runs with the shared **`Src\Common\Support\SkipsLocallyDeletedOnImport`** trait: any row whose `source_recording_id` already belongs to a soft-deleted tombstone (`onlyTrashed()`) is dropped from the batch. Re-running `f2f:import` leaves admin-deleted recordings deleted.
- **Ingestion & processing pipeline (yhy smart badge).** The badge pushes to `POST /webhooks/yhy/push` ([`YhyWebhookController`](/app/Http/Controllers/Webhooks/YhyWebhookController.php)), signature-gated by [`VerifyYhySignature`](/app/Http/Middleware/VerifyYhySignature.php) — which **always** replies the exact `status:0` ack (a non-2xx or wrong ack traps the device in a retry storm; petaV2 logged 8,300 resends of one chunk) and, in the **mirror phase**, trusts petaV2's forwarded copies (`X-Yhy-Forwarded: 1`, gated by `f2f.yhy.trust_forwarded`) without re-verifying. Two transports arrive:
  - **multipart `Audio` chunk — the LIVE path** (console storage provider = `http`). The raw `soundRecording` MP3 bytes ARE the payload. The controller stages them **synchronously** to `f2f.yhy.staging_disk` (so the bytes survive the async work), acks, and queues **[`IngestYhyAudioChunk`](/app/Jobs/F2f/IngestYhyAudioChunk.php)**: GCS upload via `MediaService` → an `f2f_recordings` row at `STAGE_UPLOADING` (**one row per chunk** — real recordings are single-`Z001`; multi-chunk reassembly is a future iteration) → the transcription chain. Deduped on the vendor `fileName` (the `source_file_name` **unique index** is the atomic guard against the relentless resend). The agent (`admin_id`) is auto-attributed from the [Device registry](/docs/modules_handbook/manage/devices/readMe.md): the badge serial is a `TYPE_BADGE` device linked to an Admin, resolved via `Device::adminIdFor(Device::TYPE_BADGE, $deviceNo)`. An unregistered badge still lands `admin_id` NULL — but only until that badge is registered, which backfills it (see *Agent attribution is retroactive* above).
  - **JSON `UploadLog` / `MergeAudio` — dormant fallback.** The merged post-call file (`data.fileDownLoadUrl`); [`ProcessYhyEvent`](/app/Jobs/F2f/ProcessYhyEvent.php) routes it to **[`DownloadYhyRecording`](/app/Jobs/F2f/DownloadYhyRecording.php)** (fetch → `MediaService` → row). Current firmware emits multipart, not this; kept as defence.

  Both land a row at `STAGE_UPLOADING` on the `redis-f2f` lane, then dispatch the **three-job transcription chain — identical in shape to Phone Call** (GUIDELINES: keep the long ASR pass off short-timeout lanes):
  - **`ProcessF2fRecording`** (redis-f2f) — thin guard on `STAGE_UPLOADING` → dispatches the next job.
  - **`TranscribeF2fRecording`** (**redis-transcription** lane, `$timeout` 600 < supervisor 650 < retry_after 700) — transcribes via the shared [`TranscriptionService`](/docs/modules_handbook/shared/transcription/readMe.md) (driver chain **Gemini primary → Deepgram fallback**); fails soft when no key is saved (row stays `STAGE_UPLOADING`, re-runnable). On success → `recordTranscription` + dispatch the analysis job.
  - **`AnalyzeF2fRecording`** (default lane) — runs the shared **[`ConversationAnalyzer`](/docs/modules_handbook/shared/conversation-analysis/readMe.md)** (the SAME analyzer + schema as Calls and Zoom — including a nested meeting report) into `ai_analysis`, then the terminal `STAGE_DONE`. Skips softly (still completes) when the analyzer is unconfigured. On a terminal failure its `failed()` hook (and a retry re-entry guard that also accepts `STAGE_ANALYZING`) ensures a row is never left stuck mid-analysis. Every showroom recording is kept (no non-sales filter).

  The transcription pass runs **only** on `redis-transcription`, never `redis-f2f`, so a long pass can never be killed mid-run and double-billed.
- **Manual upload (admin).** The Showroom page's **Upload** button opens a modal (`Partials/UploadRecordingPanel.vue`) → POST `manage.f2f.showroom.recordings.store` → **`ShowroomController@store`**: validates via `UploadF2fRecordingRequest` (audio required; agent / device / recorded-at / optional lead all optional), stores the audio under `f2f-manual/` on the f2f disk, creates the row via **`F2fRecordingRepository::createManual()`** at `SOURCE_MANUAL` + `STAGE_UPLOADING` (optionally setting `lead_id` from a lead the admin picked in the `ComboBox`), then dispatches **`ProcessF2fRecording`** — i.e. it joins the *same* `Transcribe → Analyze` chain as the webhook path, so a manual upload auto-transcribes too. `createManual` has its own whitelist (allows `lead_id`/`admin_id`), distinct from the yhy `createFromYhy` whitelist which deliberately omits them.

## Data model & petaV2 → petav3 migration
F2F recordings are produced by the smart badge in petaV2's `badge_recordings` (PropertyLab = `business_id=4`) and live in petav3's `f2f_recordings`. It's the in-person sibling of Phone Call: same recording-tied-to-customer-and-agent shape, different capture device (badge, not phone).

### Data model — `f2f_recordings` columns
From [the migration](/database/migrations/2026_06_08_000001_create_f2f_recordings_table.php) and [`F2fRecording`](/src/F2f/F2fRecording.php) (constants + casts).

| Column | Type | What it's for |
|---|---|---|
| **Identity** | | |
| `id` | bigint PK | Internal auto-increment; target of every FK. |
| `uuid` | uuid (unique) | Public identifier (route-model binding); sequential `id` never exposed. |
| **Customer link** | | |
| `lead_id` | bigint, nullable | The customer, via [Lead](/docs/modules_handbook/manage/leads/readMe.md). NULL until tagged in petav3 — the only customer link (the old `contact_id` was dropped). |
| **Agent** | | |
| `admin_id` | bigint, nullable | The agent ([`Src\People\Admin`](/src/People/Admin.php)) — **the single source of the Agent label** (`Admin::displayName()`). Live yhy pushes **auto-resolve it from the [Devices](/docs/modules_handbook/manage/devices/readMe.md) registry** by `device_sn`; **registering / restoring / updating that badge device backfills it onto existing rows with the same `device_sn` while it is still NULL** (fill-only, never an overwrite); imports resolve it via the shared `calls.sales_map`; manual upload/edit pick an Admin. NULL when the badge is unregistered / the source user isn't mapped → the row shows "—". (Phase C dropped the old free-text `salesperson_name` column — the code name stays.) |
| **Device** | | |
| `device_sn` | string(64), nullable, indexed | Badge device serial number — also the join key the [Devices](/docs/modules_handbook/manage/devices/readMe.md) registry attributes **and backfills** on (hence the index). **Not a list column** (dropped from the Showroom table); still covered by free-text search and shown/edited in the detail modal + upload/edit forms. |
| **Media** | | |
| `media_id` | unsignedBigInteger, nullable, indexed | FK → the stored audio `Media` (private GCS via `MediaService`) — the single audio pointer. `NULL` until audio is stored. |
| `duration_seconds` | int, nullable | Recording length. |
| `recorded_at` | datetime, nullable | When it was captured. yhy live rows store the device stamp (Asia/Shanghai) converted to the **app timezone** (`config('app.timezone')`, default `Asia/Kuala_Lumpur`) via [`YhyTime`](/src/F2f/Support/YhyTime.php); imported petaV2 rows keep the source value. |
| `transcript` | longtext, nullable | Plain-text transcription. |
| `deepgram_json` | json, nullable | Raw transcription-provider response (Gemini or Deepgram — column name is legacy). |
| **AI** | | |
| `ai_analysis` | json, nullable | Full AI analysis schema (`conversation_type`, `customer_profile`, `sales_performance`, `summary`, `next_actions`, `key_moments`, `personality_analysis`, …). From petaV2 `script_feedback`. |
| `ai_analysis_zh` | json, nullable | Chinese-language analysis. From petaV2 `script_feedback_zh`. |
| **Source / pipeline** | | |
| `source` | tinyint | Capture origin — `SOURCE_SMART_BADGE` (1, yhy live + petaV2 backfill) / `SOURCE_IMPORT` (2, "Manual Upload"). |
| `pipeline_stage` | string(30), nullable | Processing stage (`uploading` / `transcribing` / `analyzing` / `done` / `failed`; `finalizing` retained for rows completed before `done` existed). |
| `pipeline_error` | text, nullable | Failure detail when the pipeline errors. |
| `metadata` | json, nullable | Free-form extra data. |
| **Lineage / dedupe** | | |
| `source_recording_id` | bigint, nullable (unique) | petaV2 `badge_recordings.id` — the idempotency key for re-import (the only petaV2 lineage kept). |
| `source_file_name` | string(191), nullable (unique) | The vendor `fileName` of a live yhy push — the **atomic dedupe key** for the relentless device resend. NULL for import / manual rows. |
| **Audit** | | |
| `created_by` / `updated_by` / `deleted_by` | int, nullable | Blame actors (auto-populated; no actor for console/seeder writes). |
| `created_at` / `updated_at` | timestamp | Bookkeeping. |
| `deleted_at` | timestamp, nullable | Soft delete (synced from petaV2 during historical migration). |

### petaV2 `badge_recordings` → petav3 `f2f_recordings` mapping
From [`ImportF2fRecordings`](/app/Console/Commands/ImportF2fRecordings.php) and [`F2fRecordingRepository`](/src/F2f/Repositories/F2fRecordingRepository.php).

| petaV2 `badge_recordings` | petav3 `f2f_recordings` | Transform note |
|---|---|---|
| `id` | `source_recording_id` | Idempotency key; upsert is keyed on it. |
| `script_feedback` | `ai_analysis` | The badge's only AI-analysis column (NOT `ai_analysis`/`report_json` — those are `call_recordings`). |
| `script_feedback_zh` | `ai_analysis_zh` | Chinese analysis. |
| `business_id` | (filter only) | Filter is `business_id = 4`; not stored. |
| `user_id` | `admin_id` | `user_id` → admin via `calls.sales_map`; unmapped users land `admin_id` NULL (reported, never aborted → those rows show "—"). The raw `user_id` is not stored as lineage. |
| `lead_id` / `contact_id` / `badge_device_id` | (not stored) | The petaV2 buyer/device links are dropped; `lead_id` lands **NULL** (re-tagged in petav3). |
| `device_sn` | `device_sn` | — |
| `duration_seconds` / `recorded_at` | same | — |
| `transcript` / `deepgram_json` | same | Raw JSON preserved as-is. |
| `pipeline_stage` / `pipeline_error` / `metadata` | same | — |
| `deleted_at` | `deleted_at` | petaV2 soft-delete is source-of-truth during historical migration. |

**Lead linking is by `lead_id` only.** Unlike `calls:import`, the badge has no phone number, so there's no phone-based lead matching — every row lands unmatched (`lead_id` NULL = untagged) for the agent to re-tag in petav3 (there is no separate `status` lifecycle — tagged/untagged is derived from `lead_id`). Re-import never clobbers that manual tag work: `lead_id` is insert-only, excluded from the upsert's update set.

### Audio storage — a `Media` row on private GCS
Same as call recordings: audio is stored via the shared **`MediaService`** (private GCS) as a polymorphic `Media` row, and the recording points at it through **`media_id`** (collection `f2f-audio`). The manual upload + yhy webhook store via `MediaService::store()`/`storeUpload()`; the transcription job reads bytes via `MediaService::bytes()`; the detail modal plays a short-lived signed `MediaService::displayUrl()`. petaV2-imported rows keep `media_id` null until their audio is re-hosted. See [Media](/docs/modules_handbook/shared/media/readMe.md).

### Migration method — live import via `f2f:import` (NOT a SQL dump)
Migration runs `php artisan f2f:import` ([`ImportF2fRecordings`](/app/Console/Commands/ImportF2fRecordings.php)), which reads petaV2's live `badge_recordings` over the `petav2` DB connection, chunks through `business_id=4`, and bulk-upserts keyed on `source_recording_id` (re-runnable, idempotent).

**Why this — not the LMS-style SQL-dump-+-Seeder approach:** privacy red line. `badge_recordings` holds customer **PII and sensitive sales data** — the customer link, the full showroom conversation transcript, and the AI analysis of that conversation. Freezing that into a committed `.sql`/seeder would push customer PII into the git repo, which is unacceptable. LMS data is public course metadata with no PII, so it can be dumped and seeded; showroom recordings cannot. Hence a live, idempotent import that never persists customer data outside the database.

## Related files

**Backend — Model**
- [src/F2f/F2fRecording.php](/src/F2f/F2fRecording.php) — the recording (key model); `SOURCES` / `PIPELINE_STAGES`; `lead()` / `admin()`; **`untaggedForDevice()`** (the device-backfill's row set: a serial's `admin_id IS NULL` rows).

**Backend — Controller**
- [app/Http/Controllers/Manage/F2f/ShowroomController.php](/app/Http/Controllers/Manage/F2f/ShowroomController.php) — `index` (list + sales options + partial-reload `detail`) + `store` (manual upload → `createManual` → dispatch `ProcessF2fRecording`) + `update` (metadata edit) + `destroy` (soft-delete tombstone + GCS file removal) + `retry` (re-queue transcription).

**Backend — Repository**
- [src/F2f/Repositories/F2fRecordingRepository.php](/src/F2f/Repositories/F2fRecordingRepository.php) — `createFromYhy` (webhook), `bulkUpsert` (import), **`createManual`** (admin upload; whitelist allows `lead_id`/`admin_id`), **`update`** (metadata edit), **`backfillAdminForDevice`** (called from [Devices](/docs/modules_handbook/manage/devices/readMe.md) on a badge register/restore/update — sets `admin_id` on matching rows **where it is NULL only**; a mass update, so it passes `updated_by` explicitly since RecordsBlame's model events are bypassed), `resetForRetry`, and **`forceDeleteRecording`** (the UI's hard delete).

**Backend — Form / Query Requests**
- [app/Http/Requests/Manage/F2f/UploadF2fRecordingRequest.php](/app/Http/Requests/Manage/F2f/UploadF2fRecordingRequest.php) — manual-upload validation (audio mimetypes + `config('f2f.max_upload_kb')`; optional agent `admin_id` (`exists:admins,id`) / device / recorded_at / `lead_uuid`).
- [app/Http/Requests/Manage/F2f/UpdateF2fRecordingRequest.php](/app/Http/Requests/Manage/F2f/UpdateF2fRecordingRequest.php) — metadata-edit validation (optional agent `admin_id` / device / recorded_at / `lead_uuid`; all `sometimes` so partial edits are safe).
- [app/Http/Requests/Manage/F2f/F2fRecordingsQueryRequest.php](/app/Http/Requests/Manage/F2f/F2fRecordingsQueryRequest.php) — search / sales / source / matched / date filters.

**Backend — yhy webhook receiver**
- [app/Http/Controllers/Webhooks/YhyWebhookController.php](/app/Http/Controllers/Webhooks/YhyWebhookController.php) — `POST /webhooks/yhy/push`; branches multipart `soundRecording` (stage + queue `IngestYhyAudioChunk`) vs JSON (`ProcessYhyEvent`); always acks `status:0`.
- [app/Http/Middleware/VerifyYhySignature.php](/app/Http/Middleware/VerifyYhySignature.php) — v2 signature gate; `X-Yhy-Forwarded` mirror-trust bypass (`f2f.yhy.trust_forwarded`).
- [src/F2f/Support/YhySignatureVerifier.php](/src/F2f/Support/YhySignatureVerifier.php) · [src/F2f/Support/YhyAckResponder.php](/src/F2f/Support/YhyAckResponder.php) — the `md5(...)` header signature + the exact `status:0` ack body.
- [src/F2f/Support/YhyTime.php](/src/F2f/Support/YhyTime.php) — parses the device's UTC+8 stamps (+ ISO / ms-epoch) into the app timezone; shared by both ingest paths.

**Backend — Ingestion & processing jobs**
- [app/Jobs/F2f/IngestYhyAudioChunk.php](/app/Jobs/F2f/IngestYhyAudioChunk.php) — the LIVE path: reads the staged multipart chunk → `MediaService` (GCS) → `createFromYhy` row at `STAGE_UPLOADING` (deduped on `source_file_name`) → `ProcessF2fRecording` (redis-f2f).
- [app/Jobs/F2f/ProcessYhyEvent.php](/app/Jobs/F2f/ProcessYhyEvent.php) — JSON `dataType` fan-out; routes `UploadLog`/`MergeAudio` to the download job, logs-and-drops everything else.
- [app/Jobs/F2f/DownloadYhyRecording.php](/app/Jobs/F2f/DownloadYhyRecording.php) — dormant fallback: pulls the merged `UploadLog` MP3 to GCS, files the `f2f_recordings` row at `STAGE_UPLOADING`, dispatches `ProcessF2fRecording` (redis-f2f).
- [app/Jobs/F2f/ProcessF2fRecording.php](/app/Jobs/F2f/ProcessF2fRecording.php) — thin dispatcher (redis-f2f); guards `STAGE_UPLOADING` → `TranscribeF2fRecording`.
- [app/Jobs/F2f/TranscribeF2fRecording.php](/app/Jobs/F2f/TranscribeF2fRecording.php) — transcription via `TranscriptionService` (Gemini → Deepgram fallback) on the dedicated **redis-transcription** lane; dispatches `AnalyzeF2fRecording` on success.
- [app/Jobs/F2f/AnalyzeF2fRecording.php](/app/Jobs/F2f/AnalyzeF2fRecording.php) — shared `ConversationAnalyzer` (default lane) → `ai_analysis` + terminal `STAGE_DONE` (with a `failed()` hook). zh translation via the `translate-analysis` route (TranslatesAnalysis trait).

**Frontend (Vue)**
- [resources/js/Pages/Manage/F2f/Showroom/Index.vue](/resources/js/Pages/Manage/F2f/Showroom/Index.vue) — recordings list (standardized **Lead / Agent / Duration / Date / Stage** — no Source/Data/Device columns), filters, **Upload** button, **Devices** button, and the **Show / Edit / Delete** actions column (opens the detail modal / edit modal — which also tags the customer lead — / delete confirm).
- [resources/js/Pages/Manage/F2f/Showroom/Partials/UploadRecordingPanel.vue](/resources/js/Pages/Manage/F2f/Showroom/Partials/UploadRecordingPanel.vue) — manual-upload modal, standardized with Phone Call: audio + agent **ComboBox** (searchable across all staff → `manage.f2f.showroom.agents` / `Admin::search()`) + device + recorded-at + optional lead `ComboBox`.
- [resources/js/Pages/Manage/F2f/Showroom/Partials/F2fDetailModal.vue](/resources/js/Pages/Manage/F2f/Showroom/Partials/F2fDetailModal.vue) — **read-only** Show modal: an identity header + the shared tabbed [`RecordingDetail`](/resources/js/Components/RecordingDetail/RecordingDetail.vue) (the SAME component Zoom & Phone Call use — tabs Overview / Transcript / AI analysis / Customer / Sales performance / Meeting report; audio, retry, and petaV2 lineage live in Overview) fed by a small `detail`→normalized adapter.
- [resources/js/Pages/Manage/F2f/Showroom/Partials/F2fFormModal.vue](/resources/js/Pages/Manage/F2f/Showroom/Partials/F2fFormModal.vue) — metadata **Edit** modal (agent **Admin picker** / device / recorded-at / lead `ComboBox`, hydrated from `detail`).
- [resources/js/Layouts/ManageLayout.vue](/resources/js/Layouts/ManageLayout.vue) — sidebar nav entry.

**Migration**
- [database/migrations/2026_06_08_000001_create_f2f_recordings_table.php](/database/migrations/2026_06_08_000001_create_f2f_recordings_table.php) — `f2f_recordings` (lead_id, admin_id, device_sn, audio/transcript/AI, pipeline, `source_*` lineage). It originally created `salesperson_name`, dropped in Phase C by `2026_07_01_000040_drop_salesperson_name_from_recordings.php`.
- [database/migrations/2026_06_11_000001_drop_contact_link_fields.php](/database/migrations/2026_06_11_000001_drop_contact_link_fields.php) — drops the old `contact_id` column (F2F is lead-only).
- [database/migrations/2026_07_01_000020_add_yhy_file_name_to_f2f_recordings.php](/database/migrations/2026_07_01_000020_add_yhy_file_name_to_f2f_recordings.php) — adds `source_file_name` (unique) — the live-push dedupe key.

**Routes**
- [routes/web.php](/routes/web.php) — `manage.f2f.showroom.index` (list) + `manage.f2f.showroom.recordings.store` (POST, manual upload) + `.update` (PUT, metadata edit) + `.destroy` (DELETE, soft-delete tombstone) + `.retry` (POST, re-queue transcription).
- [routes/main.php](/routes/main.php) — `webhooks.yhy.push` (`POST /webhooks/yhy/push`, CSRF-exempt, `yhy.signature` middleware) — the vendor / petaV2-mirror ingest endpoint.

**Config / deployment (yhy)**
- [config/f2f.php](/config/f2f.php) — `yhy.enabled` (feature gate), `yhy.app_id`/`app_secret` (direct-push signature), `yhy.trust_forwarded` (mirror phase), `yhy.staging_disk`, `yhy.signature_window`.
- **Mirror phase go-live:** set `YHY_ENABLED=true` + `YHY_TRUST_FORWARDED=true`, point petaV2's mirror at `https://<host>/webhooks/yhy/push`, and raise nginx `client_max_body_size` + PHP `upload_max_filesize`/`post_max_size` to ≥ 20m (chunks are 40–640 KB). `app_id`/`app_secret` are only needed for the later direct-push cutover — until then `trust_forwarded` is what lets the mirrored copies through unverified. There is **no separate yhy spec doc**: the vendor's own "Data Push Integration §2.2 v2 with signature" scheme never matched real pushes, so the authoritative header layout is the reverse-engineered docblock on [`YhySignatureVerifier`](/src/F2f/Support/YhySignatureVerifier.php) (`timestamp` / `request-id` / `app-id` / `sign` = `md5(timestamp . request-id . app-id . app-secret)`, headers only — the body never participates) plus the comments in [`config/f2f.php`](/config/f2f.php).
