# Phone Call (Manage)

**Portal:** Manage · **Routes:** `manage.calls.*` · **Nav:** "Phone Call" (tab strip: Dashboard / **AI Agent** / Action Items / Call History / Devices / Settings — the AI Agent tab is the Phone Call twin of [Zoom's](/docs/modules_handbook/manage/zoom/readMe.md): `Manage\Calls\CallAiAgentController` → `Pages/Manage/Calls/AiAgent/Index.vue`, reusing the shared `Components/AiAgent/` RobotAvatar + AgentChatPanel and the registered `call_agent_chat` prompt; calls have **no capability toggles** — the pipeline is automatic and caller matching already exists — so the page is visibility + guidance + a grounded chat. The agent also **debriefs the agent after every call** — `App\Jobs\Calls\SendCallBrief`, dispatched by `AnalyzeCallRecording` on success: WhatsApp + email carrying customer / score / action items / follow-up date, gated on SALES EXECUTION, ledgered in `call_recording_briefs` (`Src\Call\CallRecordingBrief`, one row per recording — the row is the idempotency key, so "Re-analyze all transcripts" never re-pings). **Analyses carry cross-call memory** (`AnalyzeCallRecording::priorCallContext()` — the lead's up-to-3 prior analysed calls as a fenced preamble, mirroring Zoom). **`calls:ai-watchdog`** (hourly, `--hours=2` / `--dry-run`) releases rows stuck at queued/transcribing/analyzing to FAILED via `CallRecordingRepository::releaseStuckPipeline()` and alerts staff via the **`calls.ai_stalled`** notify event on released work or a ≥50% 24h failure rate) · **Label:** a recording's Admin owner is shown as **"Agent"** on both the History and Call Log pages (outward label only — `admin_id`, the `sales` filter key, `salesOptions` and the `salesperson` row prop are unchanged)

> **Lead → Channel → Phone Call → Insights** (2026-09-17) reads every recorded call with one person together and reports where the relationship stands — separate from the per-recording analysis described here. See [Channel Insights · Phone Call](/docs/modules_handbook/shared/channel-insights/phone-call.md).

## What it does
Brings every **sales call recording** into petav3 — historical ones **imported from petaV2** and new ones **polled live from dowayai.com** — runs each through a **transcription → AI analysis** pipeline, links it to the **customer (a [Lead](/docs/modules_handbook/manage/leads/readMe.md))** and the **agent (an Admin, via the [Device registry](/docs/modules_handbook/manage/devices/readMe.md))**, and surfaces it on **one Manage page — Phone Call** (every call, with search / filters / a detail drawer + a manual **Upload** panel + a **Devices** button). Unmatched calls stay in the same list (filter **Match = Unmatched**) and are linked to a lead **in the row's Edit modal** (the customer-lead `ComboBox`) — there is no separate Sales Contacts or Unlinked page.

> **The customer link is the lead, and only the lead.** A call resolves to a customer by **digits-only phone → `user_profiles.phone` → that user's lead** (at import time). A valid phone that matches no lead simply stays **Unmatched** (`lead_id` NULL) for a hand link — see [Leads](/docs/modules_handbook/manage/leads/readMe.md). The old WhatsApp-`contact_id` link was removed; `source_*` columns are kept only as petaV2 import lineage (audit).

## How it works
- **The recording (`CallRecording`).** Key model (uuid + blame + soft delete) on `call_recordings`. Carries the customer phone (**Scheme A: prefer callee, fall back to caller** — `CallImportMapper`), `customer_phone_source`, names, `direction`/`source`/`status`/`pipeline_stage` constants, media + AI columns, and the petaV2 `source_*` lineage. A **`NotIgnoredScope`** hides `is_ignored = true` rows (too-short / filtered clips) from every query, like soft-deletes. `customerName()` returns the caller- or callee-name depending on `customer_phone_source`.
- **Lead matching (the master list).** `LeadMatcher::matchDigits()` maps a batch of phone digits → `leads.id` in two queries (tolerant phone match, no N+1). It runs at **import time** (`ImportCallRecordings`): a call whose customer phone resolves to an existing lead is linked (`LINK_PHONE_EXACT`), otherwise it lands `lead_id` NULL (`LINK_UNMATCHED`) for a manual link. **There is no automatic phone→lead rematch** — the old `calls:rematch` command, its every-10-min scheduled run, the "Rerun match" button and the `CallLeadLinker` service (which also auto-seeded thin leads for unmatched phones) were **removed** (2026-07-17). A live-polled dowayai call carries no phone at all, so it only ever reaches a lead through the click-event **Suggestion** (Confirm) or a hand link in the row's Edit modal.
- **Unmatched = `lead_id IS NULL` (no queue table).** There is no separate unlinked queue — the Phone Call index simply filters `call_recordings` on `lead_id`. An unmatched row's **"Link to lead"** action (a lead combobox → `UnlinkedController@link`, validated by `LinkRequest` on **`lead_uuid`**) posts the `CallRecording` uuid and calls `CallRecordingRepository::linkToLead()`, which sets `lead_id` + `LINK_MANUAL` (a deliberate, audited hand-assignment); the blame columns (`updated_by` / `updated_at`) record who linked it and when. (This `@link` endpoint still exists but the UI now links through the Edit modal's `CallRecordingsController@update` → `linkToLead()` instead.)
- **Click-event guesses are SUGGESTED, never auto-linked.** A live dowayai recording arrives with **no customer phone at all** — only `admin_id` + `called_at` — so the only thing that can identify its customer is the rep's **call event** (`agent_call_events` — a WhatsApp-inbox click, a Sales Projects lead-row click, or an Android CallLog row from the companion app; all surfaced on the Call Log page). But that pairing has **no shared call id**: it is agent + time only, i.e. a guess. So `ClickEventRecordingMatcher` **never writes `lead_id`** (nor the phone — a later deterministic phone match would then re-attribute it as an authoritative `LINK_PHONE_EXACT` and silently defeat the review). It parks the guess in **`suggested_lead_id`** + **`suggested_confidence`** for an admin to accept. Three mutually exclusive states drive the Match filter: **Matched** (`lead_id` set) / **Suggested** (`lead_id` NULL, `suggested_lead_id` set) / **Unmatched** (both NULL). **Confirm** (`UnlinkedController@confirmSuggestion` → `confirmSuggestion()`) promotes it to a real `LINK_MANUAL` link and *then* adopts the click's phone/name; **Dismiss** (`@rejectSuggestion` → `rejectSuggestion()`) clears the guess, stamps `suggestion_rejected_at` (never suggested again) and **frees the click event** so it can pair with the call it actually produced. Deterministic phone matches still auto-link and never come through here. `LINK_CLICK_EVENT` remains only to render rows auto-linked under the old behaviour. **Candidate priority:** the phone rule runs first (a known phone never time-matches across numbers); among what survives, an **`android_calllog` event outranks any web click** — its `started_at` is the call's actual start, where a click is only the intent and can sit nearer under clock skew — and only when no Android candidate exists does a web click pair.
- **A misclicked intent can be DELETED from the Call Log** (2026-08-13, `DELETE manage/calls/log/{id}`, `manage-calls`, `AgentCallEventRepository::deleteMisclick()`). ⚠️ **Unpaired events only**, enforced server-side and hidden in the UI otherwise: once a recording has paired, the event is the log's carrier of that call's audio and duration, and a WRONG pairing is corrected with **Dismiss on Phone Call** (which frees the event for the call it actually produced) — never by deleting the evidence out from under it. The delete is SOFT on purpose: the row is blame-audited, and an Android event's `(admin_id, external_id)` identity is looked up `withTrashed` on replay, so a hard delete would let the device re-upload the same call as new. Pinned by `CallLogDeleteTest`.
- **Three writers feed `agent_call_events`.** *(1) Inbox click* — `Manage\Whatsapp\ContactCallsController` (unchanged). *(2) Sales Projects lead click* — the shared `LeadCell` phone button now opens a ConfirmModal ("Please confirm the phone recording machine is turned on."); OK fire-and-forgets `POST manage/leads/{uuid}/call-clicks` via fetch keepalive and then dials `tel:` (`Manage\Leads\CallClicksController` → `createFromLeadClick()`: viewLeadsAny + object-level `LeadVisibility`, lead/phone/name/admin all server-side, channel fixed `pstn`, no LeadMatcher and no thin lead — the lead is already known). **Three more surfaces mount the SAME endpoint through one shared component** (2026-08-13, [`Components/CallLogButton.vue`](/resources/js/Components/CallLogButton.vue)): the **VSL funnel roster**'s Lead cell, the **Lead page** (`Leads/Show.vue`) identity header, and the **lead quick-view modal** (`LeadDetailModal.vue`) — each a phone icon BEFORE the WhatsApp one. Identical recording-machine ConfirmModal, identical `agent_call_events` row, with one deliberate difference from the Sales Projects cell: the button **logs without dialing** (`useLeadContactActions.logCall()`, awaitable, no `tel:`), because the doway recorder is a physical handset the agent dials on, and a desktop `tel:` only opens an app-picker nobody asked for. The admin stays on the page, so a client toast (`petav3:toast` → `FlashToast`'s window-event API) is the receipt; the paired recording then surfaces on Phone Call as a suggestion like any other click. The component self-gates on the `view-leads-*` permission the endpoint enforces — a button that 403s is worse than none. *(3) Android CallLog* — the companion app's ONLY petav3 surface, `routes/agent-api.php` (plain `api` middleware group, no session/CSRF, not behind the miniprogram switch): `POST /api/agent-auth/token` + `/refresh` (tymon JWT `api` guard on the same users provider; uniform credential refusal mirroring the manage login, per-email limiter; TTLs 7d/365d in prod, see `.env.example`) and `POST /api/agent-call-events` (`createFromApp()`: idempotent on `(admin_id, external_id)` — replay answers 200 with the row untouched, the existence lookup runs `withTrashed`, a lost insert race is caught and recovered; matching is **lookup-only** — contact via `PhoneNumber::candidates()`+`sameNumber()`, lead via `LeadMatcher::matchDigits()`, and NEVER `firstOrCreateForPhone`, because the device reports every call including private ones). Heartbeat / dashboard / inbox / contacts stay on petaV2.
- **Import (historical).** `calls:import` (`ImportCallRecordings`) pulls petaV2 rows, idempotent on `dedupe_key`; it **aborts before importing** if `SalesMapResolver` finds the fixed sales map (peta user → admin) is broken, and shapes rows with `CallImportMapper`. `calls:migrate-audio` re-hosts externally-imported audio (the `source_audio_url` lineage) into `Media` (private GCS) and sets `media_id`.
- **Ingest (going forward).** `calls:poll-dowayai` (`PollDowayaiRecordings`) iterates the **active `TYPE_DOWAYAI` rows in the [Device registry](/docs/modules_handbook/manage/devices/readMe.md)** — each device IS an account (username = `identifier`, password = the encrypted `secret`, agent = `admin_id`) — logs in via `DowayaiClient` (login → `get_web_files` → `record_get` → GET the presigned OSS mp3), downloads the audio to `Media` (GCS), and creates recordings directly (attributed to the device's `admin_id`). **Every kind of recording is ingested** — real phone calls (通话, `audioType = 0`) and the rep's own voice memos (笔记, `audioType = 1`) alike. The recorder stores both, the dowayai app shows the distinction as a tag, and the API returns it as `audioType` (verified live via `calls:probe-dowayai` — every 通话 row carried 0, every 笔记 row 1); petav3 no longer acts on it, so the field is now lineage only and the old `DOWAYAI_INGEST_NOTES` opt-in is **gone**. **Duration is the ONLY ingest filter:** a clip shorter than `DOWAYAI_MIN_DURATION_SECONDS` is recorded as a minimal **`is_ignored` stub** instead (no audio, no pipeline, no click pairing — so later polls still dedup on it, and `NotIgnoredScope` hides it from every list). That single rule lives in **`Src\Call\Support\DowayaiIngestPolicy`**, so the Devices **Test connection** preview counts with the very same predicate this loop writes by — its *"N will be imported"* can never over-promise. **Accounts are managed in the UI (Manage → Devices), NOT env** — adding a rep is a new Dowayai device. Scheduled `everyMinute` behind `DOWAYAI_POLL_ENABLED`, and triggerable on demand via the Phone Call page's **Poll now** button (`POST calls/poll` → `Artisan::queue`, never inline — downloads can run minutes). All entry points (scheduler / button / CLI) share one cache lock (`calls:poll-dowayai`), so concurrent runs no-op instead of racing the unique `dedupe_key`. **dowayai specifics** (learned live): the API host is `https://www.dowayai.com:8443` (port 8443 — `:443` is the marketing site and 405s), set via `DOWAYAI_BASE_URL`; auth is account+password → JWT with **playerId + token required in the JSON body of every call, not just the header** (else dowayai answers `code=500`); and the list carries **no `createTime`** — `called_at` is derived from the 14-digit `audioFileUID` (`YYYYMMDDhhmmss`, +8 zone), matching petaV2. **The connection reference is the client itself:** the request sequence (login → `get_web_files` → `record_get` → GET the presigned OSS mp3, with `playerId` + `token` in every body) is the docblock at the top of [`DowayaiClient`](/app/Helpers/Calls/DowayaiClient.php), the per-deploy knobs are `calls.dowayai` in [`config/calls.php`](/config/calls.php) (`base_url` defaults to `DowayaiClient::BASE_URL`, so a deploy that talks to the API MUST set `DOWAYAI_BASE_URL` with the port), the accounts are rows in the [Device registry](/docs/modules_handbook/manage/devices/readMe.md), and the live payload shape is re-verified with `calls:probe-dowayai`. **A second, push-based ingest path exists** — the Android companion app uploads YHY02 BLE-badge recordings to `POST /api/agent-recordings` (see [Agent Upload](/docs/modules_handbook/manage/calls/agent-upload/readMe.md)); everything after the audio lands is identical, and the row is tagged `source = BLE Badge`. *(A `docs/modules_handbook/petav2-phonecall-doway/readMe.md` link used to sit here — that petaV2-era spec folder was never carried into this repo, so the facts above were folded into this section instead.)*
- **Relaxing the filter does NOT recover past recordings — clear the stubs first.** The stub is what keeps polling cheap (`existingExternalIds()` dedups on `withIgnored()->withTrashed()`), but it also means lowering `DOWAYAI_MIN_DURATION_SECONDS` — or removing a filter entirely, as the memo rule was — changes nothing on its own: the next poll still sees *"already had this"* and skips. **`calls:reingest-ignored`** (`ReingestIgnoredCallRecordings`) is the recovery path — it deletes those stubs so the next `calls:poll-dowayai` re-fetches them for real. The delete is deliberately a **hard** delete: a soft-deleted row still occupies the `external_id` the poll dedups on. Flags: `--dry-run` (report only), `--since=YYYY-MM-DD` (bound the blast radius by call date), `--all` (also clear stubs the *current* settings would still filter out — they simply get re-stubbed), `--force` (skip the prompt). By default it only clears stubs the current settings would actually let back in. **Re-ingesting is not free** — every recovered recording is downloaded again and put through transcription + AI analysis, so the command prints the count and asks before writing.
- **AI pipeline.** `ProcessCallRecording` (queued) is a thin dispatcher that hands off to **`TranscribeCallRecording`** (on the dedicated `redis-transcription` lane), which transcribes via the shared **`Src\Transcription`** service (driver chain **Gemini primary → Deepgram fallback**) and, on success, dispatches **`AnalyzeCallRecording`** which runs the shared **[`Src\Conversation\ConversationAnalyzer`](/docs/modules_handbook/shared/conversation-analysis/readMe.md)** (the SAME analyzer + schema F2f and Zoom use) — one structured `ai_analysis` JSON including a nested meeting report, logged to `ai_requests` via `AiClient`. Status advances `New → Transcribed → Analyzed`. (Every conversation is kept — there is no longer a non-sales filter or customer-journey aggregation.)
- **Manage surface (one page).** `HistoryController` renders the single list (filters via `HistoryQueryRequest`, incl. **Match = Matched / Suggested (needs review) / Unmatched**) with a manual **Upload** panel, a **Poll now** button (queues an on-demand dowayai pull → `CallRecordingsController@poll`), a **Call Log** button (drills into the click-to-call event log — a sub-page with a "Back to Phone Call" link), and a **Devices** button that deep-links to the [Device registry](/docs/modules_handbook/manage/devices/readMe.md) scoped to dowayai devices (`/manage/devices?type[]=2`). The list columns are the **standardized recording-list set shared with [Showroom F2F](/docs/modules_handbook/manage/f2f/readMe.md)** — **Lead / Agent / Duration / Date / Stage** / actions. **Source, Category, and the old Data icons moved off the list** (they stay in the filters + the detail/edit surfaces); the **Stage** column is the one shared pipeline vocabulary (`Src\Common\Support\RecordingStage` — Pending / Transcribing / Analyzing / Done / Failed; a petaV2-imported row with no `pipeline_stage` but real output reads as Done). The **Lead** cell shows the matched customer, or the customer's phone as a subtext hint, or an **Unmatched** badge. A row carrying a click-event guess renders it inline in the **Lead** cell — a `Suggested` badge, the proposed lead, its confidence and how long after the click the recording started — with **Confirm** / **Not this one** buttons (`unlinked.confirm` / `unlinked.reject`); this review affordance stays inline (it is not a row action). The actions column is exactly **Show / Edit / Delete** (the §14 shared pattern): **linking a call to a lead is done in the Edit modal** (`CallFormModal`'s customer-lead `ComboBox`) — `CallRecordingsController@update` routes a changed `lead_uuid` through `linkToLead()` / `unlinkLead()` (so a hand-pick is marked `LINK_MANUAL` — a deliberate, audited hand-assignment — and a cleared field unlinks); the older standalone **Link to lead** row action + `UnlinkedController@link` endpoint are no longer wired to the UI. **Show** opens a partial-reload, **read-only** detail modal (`CallDetailDrawer`) that hosts the shared tabbed `RecordingDetail` — Overview (audio, metadata, petaV2 lineage, *Retry transcription*) / Transcript / AI analysis / Customer / Sales performance / Meeting report (falls back to the imported `meeting_report_json` when the structured one is empty), the SAME component Zoom & F2f use; **Edit** opens the dedicated `CallFormModal` (the metadata form — customer name, **Agent (an Admin picker → `admin_id`)**, phones, direction, category, **and the customer-lead `ComboBox`** — hydrated from the same `detail` prop); **Delete** confirms via `ConfirmModal` then **permanently** removes the recording. `CallRecordingsController` handles upload / edit / zh-translate / delete. The **Agent** everywhere (list, detail, the Agent filter, and free-text search) is derived from `admin_id` via `Admin::displayName()` — **"Agent" is the outward label only**: the filter key stays `sales`, it keys on `admin_id`, the row prop stays `salesperson`, and `salesOptions`/`adminOptions` are `{value: adminId, label: name}` lists. All writes go through repositories inside `DB::transaction`; controllers stay thin and return `Inertia::render` / `back()`. (The old per-agent **performance** table, **Sales Contacts** page, and standalone **Unlinked** queue page were removed — the one list does it all.)
- **Five tabs now (`SectionTabs section="calls"`): Dashboard · Action Items · Call History · Devices · Settings.** *Action Items* (`/manage/calls/actions`, `ActionItemsController` + `ActionItemsQueryRequest`) is the follow-up queue as its own page — open/closed toggle with counts, agent filter, overdue flag (the AI's own `follow_up_date` passed), close/reopen per card, paginated. *Devices* deep-links the shared Device registry scoped to Doway (`/manage/devices?type[]=2`; that page mounts the calls strip when it arrives so scoped, the Setting tabs otherwise). *Settings* (`/manage/calls/settings`, `CallSettingsController`) exposes the shared `conversation_analysis` prompt + model pin (edited through the SAME Integrations → AI endpoints — versioning in one place) and **Re-analyze all transcripts**: `resetForReanalysis()` rewinds each transcript-bearing call to `PIPELINE_ANALYZING` (a valid `AnalyzeCallRecording` entry stage — transcription never re-paid) and dispatches on the `redis-ai` lane, `Cache::lock`-guarded.
- **The Dashboard has NO summary tile row** (removed deliberately — a period total with no per-agent breakdown wasn't earning its space). Layout: per-agent table FIRST (full roster: recording owners ever ∪ active Doway device owners, zero-filled rows for silent agents; "Last call" is the agent's latest call EVER → the recency chip's gone-quiet signal survives short periods), then the trend chart (one line per roster agent, auto-granularity daily/weekly at 90d, stable per-agent colors by id-sorted roster index, metric toggle calls/talk) + the data-hygiene card.
- **(superseded) Two tabs (`SectionTabs section="calls"`): Dashboard + Call History.** The sidebar entry lands on the **Dashboard** (`/manage/calls/dashboard`, `Manage\Calls\DashboardController`) — a period-scoped (7/30/90d) performance page: headline tiles (calls / talk time / avg AI score / open action items / unmatched), the **open action-item queue** (analyzed calls whose AI meeting report listed `next_steps` and nobody has closed — each row shows the items, links to the call + lead, and a **Close** button), **data-hygiene cards** (pending suggestions / unmatched calls, deep-linking to the filtered history), and the **per-agent table** (volume, talk time, matched/analyzed coverage, avg `sales_performance.score`, open actions; calls with no `admin_id` bucket as *Unassigned*). Aggregated in PHP over a bounded column select — no transcript/audio loaded.
- **Follow-up tracking.** `call_recordings.followed_up_at` / `followed_up_by` (call-level, not per-item — "did someone act on this call"). Toggled via `POST manage/calls/recordings/{id}/follow-up` (`CallRecordingRepository::setFollowedUp`); model helpers `performanceScore()` / `actionItems()` / `needsFollowUp()` read the normalized `ai_analysis` JSON. The history table gained **Score** (0–10 tone badge: ≥8 emerald, 5–7 amber, <5 red) and **Actions** (item count + close/reopen check) columns, plus a **Follow-up** filter (`open` = has items and not closed — a guarded `JSON_VALID`/`JSON_LENGTH` query on `$.meeting_report.next_steps`; `closed` = `followed_up_at` set).
- **Human review layer, below the unchanged AI score.** The Show modal's Sales performance tab now also carries a senior admin's own 0–10 score + optional comment — `HistoryController::detail()` computes `Src\Conversation\Support\RecordingReviewGate::allows()` once and passes it (+ the viewer's id) into `CallRecordingPresenter::detail()` as REQUIRED parameters (never optional — a forgotten argument must fatal, not silently mis-render 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 (the shared model, gate, endpoint and frontend components): [Conversation Analysis → Human review](/docs/modules_handbook/shared/conversation-analysis/readMe.md#human-review-the-layer-below-the-ai-score).
- **The analysis model + prompt are admin-editable** at Integrations → AI → Prompts, key `conversation_analysis` (shared with F2f + Zoom — editing it changes all three).
- **Delete removes the audio but leaves a tombstone (soft delete).** `CallRecordingsController@destroy` first removes the stored audio from GCS (`MediaService::delete` — file **and** the `Media` row) so the sensitive bytes are gone for good and the bucket is never orphaned, then **`CallRecordingRepository::softDelete()`** *soft*-deletes the row. It is **not** a hard delete, and that is load-bearing: a hard delete frees the row's `external_id` / `dedupe_key`, but the dowayai account still holds the recording, so the everyMinute poll would re-import it within a minute (`existingExternalIds()` dedupes with a `withIgnored()->withTrashed()` **presence** check on `external_id` — it only recognises a recording as already-seen if the row physically exists). The soft-deleted tombstone keeps that id occupied, so the poll (and a manual re-import) keep skipping it. The row is hidden from every list by the SoftDeletes scope; only its audio is truly gone. *(This fixed the reported "admin deletes a call, it comes back a minute later" bug — the delete used to be a hard `forceDeleteRecording()` while the sibling `softDelete()` sat unused; the dead hard-delete method was removed so the trap can't recur. The `is_ignored`-stub recovery command `calls:reingest-ignored` does its own bulk `forceDelete()` on stubs — a separate concern from an admin deleting a real recording.)*
- **Re-import never resurrects a locally-deleted recording.** The historical importer's upsert syncs `deleted_at` from petaV2, so a naïve re-run would flip an admin-deleted row (whose petaV2 twin is still live) back to visible. `CallRecordingRepository::bulkUpsert()` guards against this via the shared **`Src\Common\Support\SkipsLocallyDeletedOnImport`** trait: before upserting it drops any row whose `dedupe_key` already belongs to a soft-deleted tombstone (`withIgnored()->onlyTrashed()`). A deleted recording stays deleted, however many times `calls:import` is re-run.

## Data model & petaV2 → petav3 migration

### 1. Data model — `call_recordings` column-by-column
The whole table is one key model (`Src\Call\CallRecording`): integer `id` (PK + FK target) + public `uuid`, soft-deleted, blame-audited. Schema = `2026_06_04_000001` (create) + `2026_06_04_000002` (parity/lineage) + `2026_06_05_000006` (`is_ignored`). FK columns are logical (index-only, no DB constraint — GUIDELINES §7).

**Identity**

| Column | Type | What it's for |
|---|---|---|
| `id` | bigIncrements | Primary key; the target of every logical FK. |
| `uuid` | uuid (unique) | Public identifier (route-model binding resolves by `uuid`, never the sequential id). |

**Customer link (the lead, and only the lead)**

| Column | Type | What it's for |
|---|---|---|
| `lead_id` | unsignedBigInteger, nullable, indexed | Logical FK → `Src\Lead\Lead.id`. `NULL` = unmatched → manual-link pool. Resolved by digits-only phone. |
| `link_strategy` | unsignedTinyInteger, nullable | How the lead was linked: `LINK_PHONE_EXACT` (1, deterministic phone match at import) / `LINK_MANUAL` (2, hand-assigned in the Edit modal) / `LINK_UNMATCHED` (9, no lead). |
| `link_confidence` | string(16), nullable | Free-form confidence label for the petav3 lead link. |
| `suggested_lead_id` | unsignedBigInteger, nullable, indexed | Logical FK → `Src\Lead\Lead.id`. An **unconfirmed** click-event guess — never an attribution. Confirming copies it to `lead_id`; `lead_id` stays NULL until then, so a wrong guess never reaches the customer's history. |
| `suggested_confidence` | unsignedTinyInteger, nullable | `SUGGEST_CONFIDENCE_HIGH` (1) / `MEDIUM` (2) / `LOW` (3) — one candidate close to the call start / one but far / several for different customers. |
| `suggestion_rejected_at` | dateTime, nullable | Admin dismissed the guess — the matcher skips this call entirely from then on. |
| `customer_phone_digits` | string(30), nullable, indexed | The digits used to resolve the lead — **Scheme A: callee digits, falling back to caller** (`CallImportMapper`). |
| `customer_phone_source` | unsignedTinyInteger, nullable | Which party produced `customer_phone_digits`: `CUSTOMER_PHONE_CALLEE` (1) / `CUSTOMER_PHONE_CALLER` (2). Drives `customerName()`. |

**Names & phones**

| Column | Type | What it's for |
|---|---|---|
| `caller_name` | string(100), nullable | Display name of the calling party. |
| `callee_name` | string(100), nullable | Display name of the called party. |
| `admin_id` | unsignedBigInteger, nullable, indexed | Logical FK → `Src\People\Admin.id` (**the agent — the single source of the Agent label; `Admin::displayName()` derives it**). **Live dowayai:** resolved from the [Device registry](/docs/modules_handbook/manage/devices/readMe.md) (`Device::adminIdFor`) — per account, at poll time; unlike badges there is no `device_sn` here, and so **no backfill pass** (the F2F one is badge-only, because `call_recordings` has no column to match a dowayai account against). **Historical import:** resolved via the fixed sales map. **Manual upload/edit:** picked as an Admin. `NULL` → the row shows "—" (Phase C dropped the old free-text `salesperson_name` column — the code name stays). |
| `caller_phone_raw` | string(30), nullable | Caller phone exactly as received. |
| `callee_phone_raw` | string(30), nullable | Callee phone exactly as received. |
| `caller_phone_digits` | string(30), nullable | Caller phone, digits only (normalized). |
| `callee_phone_digits` | string(30), nullable, indexed | Callee phone, digits only (normalized). |

**Call metadata & pipeline**

| Column | Type | What it's for |
|---|---|---|
| `direction` | unsignedTinyInteger, default OUTBOUND | `DIRECTION_INBOUND` (1) / `DIRECTION_OUTBOUND` (2). |
| `source` | unsignedTinyInteger, default DOWAYAI | Ingest origin: `SOURCE_DOWAYAI` (1) / `SOURCE_MANUAL` (2) / `SOURCE_SYSTEM_APP` (3) / `SOURCE_API` (4) / `SOURCE_TEST` (5). |
| `status` | unsignedInteger, default NEW, indexed | Lifecycle: `STATUS_NEW` (1) → `STATUS_TRANSCRIBED` (2) → `STATUS_ANALYZED` (3) → `STATUS_ARCHIVED` (4). |
| `is_ignored` | boolean, default false, indexed | Kept for dedup/audit but hidden + never processed (a dowayai clip shorter than `DOWAYAI_MIN_DURATION_SECONDS`). The `NotIgnoredScope` hides these globally, like soft-deletes. Clear them with `calls:reingest-ignored` to re-import. |
| `pipeline_stage` | string(30), nullable, indexed | Free-string pipeline stage (petaV2 parity): `queued` / `transcribing` / `analyzing` / `done` / `failed`. |
| `pipeline_error` | text, nullable | Last pipeline error message, if any. |
| `call_channel` | string(20), nullable | Transport: `pstn` / `whatsapp` / `unknown`. |
| `call_kind` | string(32), nullable, indexed | petaV2 call-kind label (parity passthrough). |
| `call_category` | string(48), nullable, indexed | petaV2 call-category label (parity passthrough). |
| `duration_seconds` | integer, nullable | Recording length in seconds. |
| `called_at` | dateTime, nullable, indexed | When the call happened (petaV2 event time, **assumed UTC**). |
| `external_id` | string(100), nullable | Upstream provider id (e.g. dowayai). |
| `dedupe_key` | char(64) (unique) | sha256 idempotency key. Live polling keys on `source:external_id`; historical import keys on `petav2-call-recording:{source_recording_id}` (preserves every petaV2 row even on upstream id collisions). |
| `metadata` | json, nullable | Free-form per-recording metadata passthrough. |

**Media (pointers, not the file)**

| Column | Type | What it's for |
|---|---|---|
| `media_id` | unsignedBigInteger, nullable, indexed | Logical FK → the stored audio `Media` (private GCS via `MediaService`) — the single audio pointer (see §3). `NULL` until audio is stored / re-hosted. |
| `source_audio_url` | string(500), nullable | petaV2 external audio origin (investhink.ai etc.) — **lineage only**; `calls:migrate-audio` re-hosts it into `Media`. |
| `transcript` | longText, nullable | Final mixed-language transcript. |
| `deepgram_json` | json, nullable | Raw transcription-provider response (Gemini or Deepgram — column name is legacy). |

**AI**

| Column | Type | What it's for |
|---|---|---|
| `ai_analysis` | json, nullable | Gemini structured analysis (conversation type, intent, budget, etc.). |
| `ai_analysis_zh` | json, nullable | Chinese translation of `ai_analysis`. |
| `meeting_report_json` | longText, nullable | Gemini meeting report (EN). |
| `meeting_report_generated_at` | dateTime, nullable | When the meeting report was produced. |
| `meeting_report_json_zh` | longText, nullable | Chinese translation of the meeting report. |

**petaV2 lineage (audit-only)** — original petaV2 facts preserved alongside the petav3 logical links, for traceability/rematch/fallback display.

| Column | Type | What it's for |
|---|---|---|
| `source_recording_id` | unsignedBigInteger, nullable, indexed | petaV2 `call_recordings.id`. |
| `source_business_id` | unsignedBigInteger, nullable, indexed | petaV2 `business_id` (PropertyLab = 4). |
| `source_user_id` | unsignedBigInteger, nullable, indexed | petaV2 `user_id` (the original agent's petaV2 user). |
| `source_lead_id` | unsignedBigInteger, nullable, indexed | petaV2 `lead_id` (not used for linking — petav3 re-resolves by phone). |
| `source_contact_id` | char(36), nullable, indexed | petaV2 WhatsApp-`contact_id` (legacy link, kept as lineage only). |
| `source_contact_link_confidence` | string(16), nullable | petaV2 contact-link confidence. |
| `source_contact_link_strategy` | string(32), nullable | petaV2 contact-link strategy. |

**Audit / blame** — `created_by`, `updated_by`, `deleted_by` (unsignedInteger, nullable; auto-populated by `RecordsBlame`), `created_at` / `updated_at` (timestamps), `deleted_at` (soft delete).

### 2. petaV2 → petav3 column mapping
`calls:import` (`ImportCallRecordings`) reads petaV2 `call_recordings` (`business_id=4`, `source in (dowayai, manual)`) and shapes each row with `CallImportMapper`. Trivial columns copy across 1:1 (`caller_name`, `callee_name`, `external_id`, `duration_seconds`, raw audio/AI JSON, `metadata`, …); the load-bearing transforms are:

| petaV2 source column(s) | petav3 column(s) | Transform note |
|---|---|---|
| `id` | `source_recording_id` + `dedupe_key` | `dedupe_key = sha256("petav2-call-recording:" . id)` — idempotent re-runs, one petav3 row per petaV2 row. |
| `caller_phone` | `caller_phone_raw` + `caller_phone_digits` | Raw kept; `PhoneNumber::digits()` strips to digits. |
| `callee_phone` | `callee_phone_raw` + `callee_phone_digits` | Raw kept; `PhoneNumber::digits()` strips to digits. |
| `caller_phone` + `callee_phone` | `customer_phone_digits` + `customer_phone_source` | **Scheme A** (`CallImportMapper::customerPhone`): prefer callee digits → `source = CALLEE`; else caller digits → `source = CALLER`; else both null. |
| `customer_phone_digits` (above) | `lead_id` + `link_strategy` | `LeadMatcher::matchDigits()` resolves the chunk's digits → `leads.id` in 2 queries (no N+1). Match → `LINK_PHONE_EXACT`; no match → `lead_id=NULL`, `LINK_UNMATCHED` (manual-link pool). |
| `user_id` | `admin_id` | `SalesMapResolver` (config `calls.sales_map`) maps peta user → petav3 admin. Unmapped user → `admin_id=NULL` (reported, never aborts → those rows show "—"). `source_user_id` keeps the original. |
| `source` (string) | `source` (int const) | `CallImportMapper::source()`: `manual`→MANUAL, `system_app`→SYSTEM_APP, `api`→API, `test`→TEST, default→DOWAYAI. |
| `status` (int) | `status` (int const) | `CallImportMapper::status()`: identity for known 1–4, else NEW. |
| `direction` (string) | `direction` (int const) | `inbound`→INBOUND, else OUTBOUND. |
| `business_id` / `lead_id` / `contact_id` / `link_*` | `source_business_id` / `source_lead_id` / `source_contact_id` / `source_contact_link_*` | Carried as lineage only; petav3 does **not** link on these. |
| `deleted_at` | `deleted_at` | petaV2 soft-delete is source-of-truth during migration (re-soft-deleted on import). |

### 3. Audio storage — a `Media` row on private GCS
The DB never holds the recording bytes. Audio is stored via the shared **`MediaService`** (private GCS) as a polymorphic `Media` row, and the recording points at it through **`media_id`** (the WhatsApp-attachment pattern — see [Media](/docs/modules_handbook/shared/media/readMe.md)). Each ingest (manual upload, Dowayai poll) stores via `MediaService::store()`/`storeUpload()` (collection `call-audio`); the transcription job reads raw bytes via `MediaService::bytes()`; the History drawer / Lead page play it via a short-lived signed `MediaService::displayUrl()`; deleting a recording hard-removes its `Media` (file + row) via `MediaService::delete()`. petaV2-**imported** rows instead carry the external origin URL in **`source_audio_url`** (lineage) with `media_id` null; **`calls:migrate-audio`** (`MigrateCallAudio`) downloads that URL and re-hosts it into `Media` (setting `media_id`, idempotent on `media_id` null).

### 4. Migration method — live import, NOT a committed SQL dump
Historical migration runs through **`calls:import`**, which reads petaV2 **live** over a dedicated read connection (`DB::connection('petav2')`), is idempotent on `dedupe_key`, and **aborts before writing** if `SalesMapResolver` can't fully resolve the sales map (no silent orphans). This deliberately differs from the LMS approach (a committed `database/data/lms_content.sql` dump loaded by `LmsImportSeeder`). Why a live import instead of a dump-and-seed here:
- **Privacy red line.** `call_recordings` is full of customer PII and sensitive sales data — customer phone numbers, names, recording URLs, full conversation transcripts, and AI analysis of customer intent/budget. Freezing that into a committed `.sql` file would push customer PII into the git repo, which is unacceptable. LMS content is public course metadata (no PII), so it can safely be dumped.
- **Size.** Each row carries large transcript + AI JSON payloads, so a dump would be tens of MB in the repo.
- **Freshness.** A live read always imports the current petaV2 state, and idempotency lets it be re-run as petaV2 keeps changing.

## Prerequisites
- **Queue worker** — the AI pipeline (`ProcessCallRecording` → transcription → analysis) runs on the queue.
- **AI keys:** transcription runs Gemini (primary) → Deepgram (fallback) — both keys are configured in Manage → AI Requests → AI Providers (Gemini also has the `GEMINI_API_KEY` `.env` fallback; Deepgram is UI-only). The post-transcription Gemini analysis resolves the same Gemini key. All fail soft when unset. The petaV2 **sales map** and the shared per-deploy dowayai settings (`base_url`, timeouts, `min_duration_seconds`, `poll_enabled`) live in **`config/calls.php`**; the dowayai **accounts do NOT** — each login is a `TYPE_DOWAYAI` row in the [Device registry](/docs/modules_handbook/manage/devices/readMe.md) (added in the UI, password stored `encrypted`).
- **A read connection to petaV2** for `calls:import` (env-driven; historical migration only).

## Related files

**Backend — Models**
- [src/Call/CallRecording.php](/src/Call/CallRecording.php) — the recording (key model); customer phone (Scheme A), `customerName()`, `DIRECTIONS`/`SOURCES`/`STATUSES`/`LINK_STRATEGIES`; `lead()` / `admin()`; `NotIgnoredScope`.
- [src/Call/Scopes/NotIgnoredScope.php](/src/Call/Scopes/NotIgnoredScope.php) — hides `is_ignored` rows globally.

**Backend — Repositories**
- [src/Call/Repositories/CallRecordingRepository.php](/src/Call/Repositories/CallRecordingRepository.php) — bulk upsert (import), manual/dowayai create, edit, soft delete + **`forceDeleteRecording()`** (the UI's hard delete), transcription/analysis writes, the manual **`linkToLead()`** / **`unlinkLead()`** (Edit-modal customer link → `LINK_MANUAL` / back to `LINK_UNMATCHED`), and the click-event suggestion lifecycle — **`applySuggestion()`** (park a guess) / **`confirmSuggestion()`** (accept → `LINK_MANUAL` + adopt the click's phone/name) / **`rejectSuggestion()`** (dismiss, stamp `suggestion_rejected_at`, free the click event).

**Backend — Services & Support**
- [src/Call/Support/LeadMatcher.php](/src/Call/Support/LeadMatcher.php) — phone digits → lead (2 queries, no N+1). The single source of truth for call→lead resolution.
- [src/Call/Support/CallImportMapper.php](/src/Call/Support/CallImportMapper.php) — pure mapping for import; Scheme A customer phone.
- [src/Call/Support/SalesMapResolver.php](/src/Call/Support/SalesMapResolver.php) — sales-map preflight (peta user → admin) for import.

**Backend — Shared AI analysis (`Src\Conversation`)** — the provider-agnostic sales-conversation analyzer shared by Calls / F2f / Zoom. Service handbook: [Conversation Analysis](/docs/modules_handbook/shared/conversation-analysis/readMe.md).
- [src/Conversation/ConversationAnalyzer.php](/src/Conversation/ConversationAnalyzer.php) — `analyze()` (one schema, via `AiClient`) + `translate()`; configurable provider/model.

**Backend — Shared transcription (`Src\Transcription`)** — the provider-agnostic ASR service. Service handbook: [Transcription](/docs/modules_handbook/shared/transcription/readMe.md).
- [src/Transcription/TranscriptionService.php](/src/Transcription/TranscriptionService.php) — injectable entry point; resolves the configured driver.
- [src/Transcription/Drivers/DeepgramTranscriber.php](/src/Transcription/Drivers/DeepgramTranscriber.php) — Deepgram pre-recorded transcription, dual zh+en pass (fails soft).
- [src/Transcription/BilingualTranscriptMerger.php](/src/Transcription/BilingualTranscriptMerger.php) — merge Deepgram zh + en passes into one transcript.

**Backend — External clients (`App\Helpers\Calls`)**
- [app/Helpers/Calls/DowayaiClient.php](/app/Helpers/Calls/DowayaiClient.php) — dowayai.com HTTP client (`Http::fake`-testable); **its class docblock is the protocol reference** (the login → `get_web_files` → `record_get` → OSS-mp3 sequence, and why `playerId`/`token` must ride in the JSON body). (Analysis now runs through the shared `ConversationAnalyzer` / `AiClient` — the old `GeminiService` / `GeminiTransport` helpers were removed.)

**Backend — Controllers**
- [app/Http/Controllers/Manage/Calls/HistoryController.php](/app/Http/Controllers/Manage/Calls/HistoryController.php) — the single Phone Call list + read-only detail drawer.
- [app/Http/Controllers/Manage/Calls/CallRecordingsController.php](/app/Http/Controllers/Manage/Calls/CallRecordingsController.php) — upload / edit / translate / delete.
- [app/Http/Controllers/Manage/Calls/UnlinkedController.php](/app/Http/Controllers/Manage/Calls/UnlinkedController.php) — the manual `link` action (`→ CallRecordingRepository::linkToLead()`; the UI now links through the Edit modal instead, but the endpoint remains) and the click-event review actions (`confirmSuggestion` / `rejectSuggestion` — the only path from a guess to `lead_id`). No index page; unmatched calls — `lead_id IS NULL` — are surfaced inside the Phone Call list via the Match filter.

**Backend — Form / Query Requests**
- [app/Http/Requests/Manage/Calls/UploadCallRecordingRequest.php](/app/Http/Requests/Manage/Calls/UploadCallRecordingRequest.php) · [UpdateCallRecordingRequest.php](/app/Http/Requests/Manage/Calls/UpdateCallRecordingRequest.php) · [LinkRequest.php](/app/Http/Requests/Manage/Calls/LinkRequest.php) — upload / edit / manual-link (`lead_uuid`).
- [app/Http/Requests/Manage/Calls/HistoryQueryRequest.php](/app/Http/Requests/Manage/Calls/HistoryQueryRequest.php) — list filters (search / sales / direction / source / status / matched / date).

**Backend — Jobs**
- [app/Jobs/Calls/ProcessCallRecording.php](/app/Jobs/Calls/ProcessCallRecording.php) — thin dispatcher → `TranscribeCallRecording`.
- [app/Jobs/Calls/TranscribeCallRecording.php](/app/Jobs/Calls/TranscribeCallRecording.php) — transcription (Gemini → Deepgram) on `redis-transcription`; on success → `AnalyzeCallRecording`.
- [app/Jobs/Calls/AnalyzeCallRecording.php](/app/Jobs/Calls/AnalyzeCallRecording.php) — analysis via the shared `ConversationAnalyzer` → `ai_analysis`.

**Backend — Console Commands**
- [app/Console/Commands/ImportCallRecordings.php](/app/Console/Commands/ImportCallRecordings.php) — `calls:import` (petaV2 → petav3, idempotent).
- [app/Console/Commands/PollDowayaiRecordings.php](/app/Console/Commands/PollDowayaiRecordings.php) — `calls:poll-dowayai` (live ingest per account).
- [app/Console/Commands/ReingestIgnoredCallRecordings.php](/app/Console/Commands/ReingestIgnoredCallRecordings.php) — `calls:reingest-ignored`, the recovery path after a filter is relaxed: hard-deletes the hidden `is_ignored` stubs so the next poll re-fetches them (`--dry-run` / `--since` / `--all` / `--force`).
- [src/Call/Support/DowayaiIngestPolicy.php](/src/Call/Support/DowayaiIngestPolicy.php) — the single ingest-filter rule (minimum duration), shared by the poll's WRITE and the Devices Test-connection COUNT so the preview cannot over-promise.
- [app/Console/Commands/MigrateCallAudio.php](/app/Console/Commands/MigrateCallAudio.php) — `calls:migrate-audio` (re-host `source_audio_url` → `Media`, set `media_id`; idempotent on `media_id` null).
- [app/Console/Commands/ProbeCallMatchRate.php](/app/Console/Commands/ProbeCallMatchRate.php) — M0 read-only prod stats gate (writes nothing).
- [app/Console/Commands/ProbeDowayaiResponse.php](/app/Console/Commands/ProbeDowayaiResponse.php) — `calls:probe-dowayai`, a read-only dump of the raw `get_web_files` payload (all keys, candidate enum fields, a fileName/`audioType`/duration table). The ingest reads only a few fields, so this is how the API's real shape is verified — it is what identified `audioType` as the 通话/笔记 discriminator. Writes nothing, downloads no audio.

**Config**
- [config/calls.php](/config/calls.php) — the fixed `sales_map` (doway login → peta user), the audio
  disk / upload cap, and the dowayai **poll settings** (`base_url`, timeouts, `min_duration_seconds`,
  `poll_enabled`). ⚠️ The dowayai **credentials are NOT here and not in `.env`** — each doway login is
  a `TYPE_DOWAYAI` row in the Device registry (see *Prerequisites*); this file only says how to talk
  to them.
- [config/services.php](/config/services.php) — Gemini fallback and non-secret model defaults; Deepgram's runtime key lives in AI Providers.

**Frontend (Vue)**
- [resources/js/Pages/Manage/Calls/History/Index.vue](/resources/js/Pages/Manage/Calls/History/Index.vue) — the single Phone Call list: standardized **Lead / Agent / Duration / Date / Stage** columns (shared with F2f), search, filters (incl. Match), Upload panel, **Poll now** + **Call Log** + **Devices** buttons, and the **Show / Edit / Delete** actions column (Edit's `ComboBox` links the lead; the click-event suggestion Confirm/Dismiss stays inline in the Lead cell).
- [resources/js/Pages/Manage/Calls/History/Partials/](/resources/js/Pages/Manage/Calls/History/Partials/) — `CallDetailDrawer.vue` (**read-only** Show — an identity header + the shared tabbed [`RecordingDetail`](/resources/js/Components/RecordingDetail/RecordingDetail.vue) fed by a small `detail`→normalized adapter), `CallFormModal.vue` (metadata Edit), `UploadRecordingPanel.vue` (manual upload — standardized with F2f: audio + agent **ComboBox** (searchable staff → `manage.calls.agents` / `Admin::search()`) + customer-lead `ComboBox` + recorded-at + direction). The lead picker is the shared [`ComboBox.vue`](/resources/js/Components/ComboBox.vue); the analysis + transcript + audio are the SAME tabbed component Zoom & F2f use (tabs: Overview / Transcript / AI analysis / Customer / Sales performance / Meeting report — the Meeting report tab falls back to the raw petaV2 `meeting_report_json` when the structured `ai_analysis.meeting_report` is empty).
- [resources/js/Layouts/ManageLayout.vue](/resources/js/Layouts/ManageLayout.vue) — sidebar nav (a single **Phone Call** link, no longer a Calls group).

**Migrations**
- [database/migrations/2026_06_04_000001_create_call_recordings_table.php](/database/migrations/2026_06_04_000001_create_call_recordings_table.php) — the core table.
- `2026_06_04_000002_add_call_history_parity_fields_to_call_recordings.php` — petaV2 parity + `source_*` lineage.
- `2026_06_05_000003_create_unlinked_recordings_table.php` — the (removed) manual-link queue; **dropped by [`2026_07_16_000001_drop_unlinked_recordings_table.php`](/database/migrations/2026_07_16_000001_drop_unlinked_recordings_table.php)** now that "unmatched" is just `lead_id IS NULL`. The create migration is left untouched per GUIDELINES §7.
- [`2026_07_16_000002_add_suggested_lead_to_call_recordings.php`](/database/migrations/2026_07_16_000002_add_suggested_lead_to_call_recordings.php) — `suggested_lead_id` / `suggested_confidence` / `suggestion_rejected_at`: the click-event guess awaiting admin review.
- `2026_07_01_000003_drop_customer_journey_reports_table.php` — drops the customer_journey_reports table (Customer Journey removed; the create migration is left untouched per GUIDELINES §7).
- `2026_06_05_000006_add_is_ignored_to_call_recordings.php` — the `is_ignored` flag.
- [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 WhatsApp-contact link columns + `agent_call_events` (calls are lead-only).
- [database/migrations/2026_07_01_000040_drop_salesperson_name_from_recordings.php](/database/migrations/2026_07_01_000040_drop_salesperson_name_from_recordings.php) — Phase C: drops `salesperson_name` from `call_recordings` **and** `f2f_recordings` (the agent is derived from `admin_id`).

**Routes**
- [routes/web.php](/routes/web.php) — `manage.calls.*` group (history index, log index, poll, unlinked link/confirm/reject — no index, recordings store/update/translate-analysis/destroy/retry) + `manage.leads.call-clicks.store` (the Sales Projects phone button).
- [routes/agent-api.php](/routes/agent-api.php) — the companion-app surface: `agent-auth/token` / `agent-auth/refresh` / `agent-call-events` (JWT `api` guard, `AgentApi\AgentAuthController` + `AgentApi\AgentCallEventsController`).
