# Leads (Manage)

**Portal:** Manage · **Routes:** `manage.leads.*` · **Nav:** "Leads"

## What it does
Manages the people captured by the public [Landing & Lead Capture](/docs/modules_handbook/main/landing-lead-capture/readMe.md) funnels. A **lead is a person** (one per account); each funnel/program they register for is a **registration row** carrying its **own first-touch ad/source attribution**. So one person who registers for two funnels is **one lead with two registrations** (it shows under both funnels' Leads). Admins list/search/filter, change status, view per-registration attribution, edit contact details, and delete.

> A lead row stores **only** `user_id` + `flg_lead_id` + `status` (the person). **Attribution + the funnel live on `lead_funnels`** (one row per `(lead, funnel)`, `unique`). Name / email / phone live on the linked `User` + `UserProfile` (single source of truth).

## How it works

### `LeadLinker` — the unified linking shell (2026-07-17)
The gate below unifies **who-is-who**. Everything *around* it — whether a phone may be trusted as a key,
what attribution is recorded, what outcome is reported — is unified by the shared **`LeadLinker`** service,
which wraps this gate and never reimplements it. **Any code that links or creates a lead should go through
it**, and its handbook is the one to read first:

> **→ [Lead Linking (`LeadLinker`)](/docs/modules_handbook/shared/lead-linking/readMe.md)** — the service, the
> phone-trust policy (**the account-takeover boundary**), the outcome vocabulary, attribution, and a
> *Reference usage* pointing at a real consumer.

> **Username-era note (2026-07-22):** a WhatsApp contact whose phone is HIDDEN (an `@lid` / BSUID-only
> contact — see the [WhatsApp LID readiness](/docs/modules_handbook/manage/messages/whatsapp/group.md) section)
> is **UNLINKABLE by design**: the identity gate needs an email or phone, so no lead is created and no
> RBAC assignment happens until the phone is learned — at which point the normal link paths (live
> `considerLinkContact`, the hourly `whatsapp:link-contacts --existing-only` sweep) pick it up
> automatically. This is the safety posture, not a gap.

**Consumers migrated so far:** the manual **"New lead"** create (`LeadsController@store`, with `StoreRequest`
previewing through the same classifier), WhatsApp's **`ContactLinker`**, the **member/lead CSV import**
(`BulkMemberImportAction`), the **legacy property-bookings import** (`ImportLegacyBookingsAction`), and
**Zoom poll respondents** (`PollRespondentLinker`, a backfill sweep). The old **Portal invite** path — once
the census's only true bypass — was **removed** (`Manage\Portal\UsersController` is read-only now; creating a
portal user is the Leads "New lead" modal). **Still bypassing the linker** — each resolves identity by hand
one level down at the gate, and its reserved `SOURCE_*` constant is declared but unused: the public
`/register` flow (`RegisterLeadAction`), Meta Lead Gen sync (`SyncFlgLeadToCrmAction`), event walk-in
check-in (`CheckInController`), Stripe webhooks (`WebhookProcessor`), and FB owner-listing
(`ProcessOwnerListingJob`). The full census is there.

### Identity resolution — ONE person = one User + one Lead (2026-07-14)
A person is deduped by **email OR phone**, and every path that could create a lead/user funnels through
the **central gate** `LeadRepository::firstOrCreateForIdentity(email, phone, name, groupId, role)`:
- **Email** matches exactly (MySQL `utf8mb4_unicode_ci`, so case-insensitive). **Phone** matches
  **tolerantly** — `user_profiles.phone` stores digits-only *keeping* the trunk-0 (`Src\Common\Support\PhoneNumber::digits`),
  so the same number legitimately exists as `0123456789` **and** `60123456789`; matching expands every
  candidate shape (`PhoneNumber::candidates`) and verifies each hit with `PhoneNumber::sameNumber`
  (canonical-E.164 equality) so a loose shape can never bind a *different* person.
- **Progressive backfill:** a person known by one key, later seen with the other, has the missing key
  filled in (`backfillContact`, which self-guards — only writes a blank field, and only when the value
  isn't owned by another account) instead of getting a second record.
- **Conflict** (email → A, phone → B, two different people) is never silently merged — the **email owner
  wins** and B's phone is not merged (`resolveUserByIdentity` returns `conflict`).
- **Staff/admins are never turned into a customer lead** (the gate returns null; the form entry points
  reject a staff match).
- **Every minted lead gets a main-portal role.** The gate's `$role` defaults to **`NON_MEMBER`**, so an
  account it creates is always a first-class customer. It used to default to `null` and only *some*
  callers passed a role, so every lead minted by **WhatsApp `ContactLinker` / Stripe / call matching /
  purchase-history import** was left **role-less** — and a role-less account is silently second class:
  it is not an `isMainUser()`, so `EnrollMemberAction::canEnroll()` refuses it (the UI then claimed
  *"This account is an admin and cannot be made a member"* — it is not), `SyncMembershipRoleAction`
  skips it, and the magic-link gate will not send it a sign-in link. Existing rows were repaired by
  [`2026_07_17_000001_backfill_main_role_for_role_less_leads`](/database/migrations/2026_07_17_000001_backfill_main_role_for_role_less_leads.php)
  (only accounts with **no** role at all; staff — including soft-deleted `admins` rows — untouched;
  an active subscription stamps `MEMBER`, else `NON_MEMBER`).
  - The guarantee covers **both** ways the gate creates a lead: a brand-new account (role passed to
    `UserRepository::create`) **and** a lead attached to an account that already existed without one —
    that second branch fills a **blank role set** the same way it backfills a blank email/phone. An
    account holding **any** role is never re-roled, so a paying Member is never knocked back to
    Non-Member by an inbound WhatsApp touch.
  - **Enrolment paths self-heal.** `EnrollMemberAction::ensureMainUser()` (shared by the CSV importer,
    the lead's **Convert to member** action and the membership **Add members** modal) stamps `NON_MEMBER`
    on a role-less *customer* before enrolling, and `EnrollMemberAction::isStaff()` is what those paths
    test to decide whether to say *"staff account"* — `canEnroll()` alone cannot tell "is staff" from
    "merely missing a role", which is what produced the misleading message.
- `firstOrCreateForPhone` / `firstOrCreateForEmail` are thin wrappers over the gate, so every consumer
  (Stripe, imports, WhatsApp `ContactLinker`, the call module's `LeadMatcher`, FB owner-listing) shares
  one tolerant rule. See the [WhatsApp contact↔lead](/docs/modules_handbook/manage/messages/whatsapp/broadcast.md) linking.
- **`create()` uses `Lead::firstOrCreate`** (not updateOrCreate) so re-ingesting a returning person never
  clobbers their pipeline **status** (only fills a blank `flg_lead_id` / group).
- **⚠️ The public `/register` flow matches by EMAIL ONLY** (`RegisterLeadAction::resolveAccount`). It must
  never resolve/enrich an account via the unverified typed **phone**, because it then mails a magic
  sign-in link — phone-based merging there is an account-takeover vector (an attacker typing a victim's
  phone + their own email). Tolerant phone merge is only safe on admin / background-ingestion paths.

- **Admin quality verdicts — Agent / Fake, the human override (2026-08-17).** The AI's two quality guesses ([enrichment](/docs/modules_handbook/manage/leads/enrichment/readMe.md) `is_property_professional` + `fake_score`) can be **confirmed or overridden by a human**: `leads.agent_verdict` / `leads.fake_verdict` (0 no / 1 yes / NULL never judged — read with a null check, never falsy), each stamped with `_by` + `_at` (migration `2026_08_17_000001`). They live on **`leads`, not `lead_enrichments`**, for the same reason `properties_owned` does: the enrichment row is regenerated on re-run, and a never-enriched lead must still be taggable. A set verdict **always outranks the AI** — `Src\Lead\Support\LeadQuality` resolves the precedence once (`BuildsLeadInsight` and the Leads index both delegate to it), so the VSL / Registrations roster tags follow automatically (an admin-confirmed tag's tooltip reads "Confirmed by admin"; a human *genuine* silences the AI "Fake?" and a human *not an agent* silences "Agent-linked"). The UI is the shared **[`Components/LeadQualityTags.vue`](/resources/js/Components/LeadQualityTags.vue)** — tags + a "Verdict" dropdown (showing the AI's current guess as context) mounted in **both** identity headers: the Show page (Inertia write) and the `LeadDetailModal` (axios write → `?section=lead` re-pull + a host `router.reload()` so roster tags under the modal update too). Writes go to `POST manage/leads/{id}/verdict` (`VerdictRequest`: `kind` agent|fake + nullable `verdict`; `MANAGE_LEADS` + `LeadVisibility`) → `LeadRepository::setAgentVerdict()` / `setFakeVerdict()`; clearing (null) hands the surface back to the AI guess. Constants + labels: `Lead::VERDICT_NO/YES`, `AGENT_VERDICTS` / `FAKE_VERDICTS`. Pinned by [LeadQualityVerdictTest](/tests/Feature/Lead/LeadQualityVerdictTest.php) (endpoint, precedence, clear-restores-AI, tag-without-enrichment-row, payload).
- **Shared quality-state resolver — Agent / Fake filters, counts & export (Phase 2–5, 2026-08-18).**
  `Src\Lead\Support\LeadQuality` turns the two verdict pairs above into ONE effective state string
  per dimension (`agent`/`agent_linked`/`not_agent`/`unjudged`, `fake`/`genuine`/`unjudged`) plus
  WHO decided (`agent_source`/`fake_source`: `admin`|`ai`|null) — the single place admin-vs-AI
  precedence is resolved, as both a PHP method (`agentState()`/`fakeState()`, used by
  `BuildsLeadInsight` for the roster payload and the index row's `transform()`) and the SAME
  mapping as a SQL `CASE` fragment (`agentStateSql()`/`fakeStateSql()`, a correlated subquery on
  `lead_enrichments` keyed by its own unique `lead_id` — never a join, GUIDELINES §14). The Leads
  index filters on it (`agent[]`/`fake[]` in `LeadQueryRequest`, a whitelisted `whereRaw`) with
  faceted `agentCounts`/`fakeCounts` (`LeadsController@index`, grouped by the **SELECT ALIAS** —
  `groupByRaw(<expression>)` is a latent `ONLY_FULL_GROUP_BY` 500 the moment that MySQL mode
  tightens, caught in QA). The export (`LeadsExport`) adds **`Agent verdict`** / **`Fake verdict`**
  TEXT columns carrying the resolved LABEL (never the int), `''` when unjudged. **The "Fake?"
  hedge**: an AI-SCORED fake reads `"Fake?"` — never presented with a human confirmation's
  certainty, `"Fake"` — resolved by `LeadQuality::fakeLabel($state, $source)` (`agentLabel()`
  alongside it, though the agent dimension has no hedge to resolve), so the export,
  `QualityTagChips.vue` and `LeadQualityTags.vue` can never disagree on the wording.
- **The shared tag component.** `resources/js/Components/Leads/QualityTagMeta.js` is the single
  label/colour/icon source on the frontend (mirrors `LeadQuality::AGENT_STATES`/`FAKE_STATES` 1:1)
  plus `qualityStates(insight)` — the ONE place the **`insight === null` contract** is applied:
  `agent_state`/`fake_state` default to `'unjudged'`, both sources to `null`, since
  `registrantInsight()` returns `null` (and the index row omits the keys) for ~88% of leads
  (never enriched, never judged) — every consumer spreads `qualityStates(insight)` rather than
  reading `insight.agent_state` unguarded. `Components/Leads/QualityTagChips.vue` is the
  display-only row-cell rendering (Fake → Agent order, a `+N` popover on hover/tap/focus, an
  admin-confirmed "clean" state shown while the AI equivalent is silence), mounted on the Leads
  index, the VSL funnel roster and the session Registrations roster — replacing what used to be
  three separately hand-rolled pill blocks. `matchesQualityOption(insight, 'all'|'agent'|'fake'|
  'clean')` is the roster TRIAGE-bucket definition (folds `agent_linked` into Agent; `clean`
  includes an admin-cleared row exactly like a never-judged one) shared by
  `useVslRosterFilters.js`'s QUALITY pill group and `RegistrationsTab.vue`'s drawer dimension —
  **the two rosters keep DIFFERENT filter idioms on purpose** (VslLeadsTab.vue is already an
  all-pills roster; RegistrationsTab.vue is already a `FilterDrawer` roster, and its `quality`
  dimension carries a `counts` map computed the same way `source`/`campaign` are) but read the
  SAME predicate, so the two can never disagree on what counts as Agent / Fake / Clean.
- **`prof_source` (2026-08-18) — data-only so far.** `lead_enrichments.prof_source`
  (`own_bio`/`search`/`ai_report`) records WHICH tier produced the stored
  `is_property_professional`, enforcing a merge trust order **own_bio > ai_report > search** (see
  the [enrichment handbook](/docs/modules_handbook/manage/leads/enrichment/readMe.md)'s "Quality
  verdicts" section for the full rule). ⚠️ **Nothing walks a weak verdict back yet** — a stored `search`-tier
  RELATED is not cleared by a later `ai_report` NO, because the merge compares verdict RANK first
  and only breaks a same-rank TIE by source tier. A genuine walk-back mechanism is a follow-up
  task, not built here.
- **Admins are full leads, badged "Admin" (`leads.is_staff`).** Every admin carries a Lead facet (their own portal identity — so staff can use + write the member-portal features), flagged `is_staff`. This is a **label, not a filter**: an admin-lead is **visible everywhere a lead is** (list, counts, pipeline), and the list transform emits `is_staff` so the name cell shows an **Admin** badge. Promoting a customer to admin therefore **keeps all their records** visible. See the [identity foundation](/docs/modules_handbook/shared/user-lead-admin-login-register-merge/readMe.md) → *Admin + Lead are hats*.
- `Lead` belongs to a `User` and `hasMany` **`leadFunnels`** (`Src\Lead\LeadFunnel`, the registrations) + `belongsToMany` **`funnels`** through `lead_funnels`. `STATUSES` is on `Lead`; **`SOURCES`** (**Funnel(9) / Meta Lead Gen(6) / Owner Listing(5) / Property Match(8) / Other(4)** — the 2026-07 consolidation retired FMX/MKT/Organic/Messenger, remapped by migration `2026_07_24_000001`; "which agency / paid-or-organic" lives in the row's own `utm_*` columns and "which ad account" resolves via `campaign_id`/`ad_id` → `meta_ad_refs`) is on **`LeadFunnel`** (source is per registration). Every landing registration stamps **Funnel**; ingestion paths that used to leave a blank source (Stripe, walk-in, WhatsApp, phone-call) now stamp an un-funneled **Other** row via `LeadFunnelRepository::attachDefaultSource` (existing attribution is never diluted).
- **A registration also banks the two Meta browser ids, `fbp` + `fbc` (2026-07-29).** Every landing registration writes them alongside the `fbclid` (`LandingController` reads `_fbp` from the cookie and derives `_fbc` via `clickId()`, `RegisterLeadAction` persists both onto the `lead_funnels` row). They are **read back long after the visit** by two consumers, which is why they are stored rather than just forwarded to Meta at the moment of the conversion:
  - `App\Actions\Marketing\ReportPurchaseToMetaAction` replays `$touch->fbp` / `$touch->fbc` on the server-side **Purchase** event — money is confirmed by a gateway webhook with no browser and no cookies, so these are the strongest click→conversion link Meta still has.
  - `AdPerformanceService` uses `fbc` (with `fbclid` / `campaign_id` / `ad_id`) as one of the four "came from an ad" markers in its **first-touch** correlated subquery — the same broad test as `ReportPurchaseToMetaAction::firstAdTouch()`, ordered `registered_at, id` so the credited ad never flip-flops. A lead with **no** ad-marked registration is silently skipped, by design.
- Index list filtering goes through `LeadQueryRequest`: **search** queries the related user + profile; **funnel** filter = `whereHas('funnels', uuid)`; **source** filter = `whereHas('leadFunnels', marketing_source)`; **agent** / **fake** filter = `whereRaw(LeadQuality::agentStateSql()/fakeStateSql() . ' in (…)')` (see the shared quality-state resolver bullet above). The funnel + source **counts** are `count(distinct lead_id)` over `lead_funnels`; the agent + fake counts are grouped by the state SQL's own select alias.
- The controller transforms each lead with `with(['user.profile', 'leadFunnels.funnel'])` (no N+1): name/email/phone from the profile, a **`funnels`** chip array (name + source per registration) for the list column, and a **`registrations`** array (full per-registration attribution) for the expand row + Show "Attribution" tab.
- **Edit** writes contact (name, email, phone) to the linked account/**profile** via `UserRepository` (`LeadsController::update`); **status is not edited here** — it is a synced roll-up (see below). **Create** (admin) records the chosen source as an **un-funneled registration** (`LeadFunnelRepository::attach($lead, null, …)`).
- **Lifecycle is now per project.** Each project a lead is worked on is a separate **engagement** (Caller → Closer → Follow-Up), managed on the Show **"Pipeline"** tab — see [Engagements & Bookings](/docs/modules_handbook/manage/engagement/readMe.md). `leads.status` (the flat 5-value enum) is kept only as a **synced roll-up** of those engagements (via `App\Actions\SyncLeadStatusFromEngagements`); it is shown as a **read-only badge** on the list (plus a "Pipelines" summary column) and is no longer edited inline. (The edit form's status field remains for legacy leads that have no engagement.)
- **Export** is one row **per registration** (`LeadsExport` flattens `leadFunnels`), with a Funnel column, plus **`Agent verdict`** / **`Fake verdict`** TEXT columns carrying the resolved LABEL (the "Fake?" hedge for an AI guess, `''` when unjudged — see the shared quality-state resolver bullet above).
- **Import leads from CSV/Excel** (the "Import CSV" button → shared `ImportCsvModal`, `mode='leads'`). A **preview** (`importPreview` → `BulkMemberImportAction::preview`, MODE_LEADS) is run on upload and **writes nothing** — the modal (a wide `4xl` dialog) shows the **detected columns**, an **example file** to download, and a **count summary** (`To create` = new person/lead · `Already exist` = matched an existing lead by email or phone, only blanks filled, no duplicate · `Needs review` = matched an existing person by ONE key but the OTHER key (phone/email) in the file **differs** from what is on file, and that new value belongs to nobody else → never silently trusted; the admin **decides per row** (see below). **Order matters (fixed 2026-07-17):** every *"nothing to action"* verdict — a staff match, and **already an active member of this tier** — is now decided BEFORE the needs-review flag, in **both** `classify()` and `applyRow()`. They used to sit after it, so someone already enrolled whose CSV row carried a different email/phone was dragged into *Needs your decision* and counted on the confirm button — for a question (*enrol them?*) that had no meaning for them. Nothing was ever written wrong (`apply()` still reported `already_member`), but the count disagreed with the outcome and the admin was asked to settle something already settled · `Email/phone mismatch` = the two keys belong to two different people → skipped · `No email/phone` / `Skipped`). The counts are split into **four groups** — **Will be imported** (create / matched / unmatched), **Needs your decision** (needs-review), **Already in the system** (existing / already-members, a no-op), and **Skipped — will not be imported** (mismatch / no-email-phone / staff-or-toggle skip) — so the admin sees at a glance what happens. Each count is an **expandable section** carrying a one-line meaning under its label; click it to see the actual rows behind the number (file line, name, email, phone; + amount in enrol mode; capped at the first 300 rows per category), and each has a **Download CSV** button that exports **all** of that category's rows (UTF-8 BOM so Excel opens it cleanly). A **Needs review** row asks the admin ONE question — *is this the same person?* In **enrol mode** each flagged row gets a **checkbox** (plus a **Select all**): ticked → the row is processed as a normal match and the person is **enrolled using the email/phone already in the system**; unticked (the default) → skipped. The cell labels both values explicitly — the stored one *"in the system · will be used"*, the CSV's one struck-through and *"in your CSV · ignored"*. (Copy note: never write **"on file"** here — on a screen about uploading a file it reads as *your* file and means the exact opposite. Say **"in the system"** vs **"in your CSV"**.) Ticked rows ride the same "enrol existing leads" toggle and are added to the confirm count; the card shows *"N of M ticked to enrol"*. In **leads mode** there is no enrolment, so a flagged row is **informational only** — listed and downloadable for a human to reconcile. Only the ticked **row indexes** are posted (`review_confirmed`, one JSON array so a big flagged set can't silently blow PHP's `max_input_vars`); no contact value is ever sent, and a confirmation aimed at a row that is not flagged is inert. The diff is also written into the category's CSV export (`old → new`).
  - **Conflict rows can optionally be AUTO-MERGED (added 2026-07-21).** An `Email/phone mismatch` row (the email owned by one account, the phone by another) is skipped by default — but the leads-mode preview shows a **"Merge the N mismatched pairs"** tickbox (off by default) plus, per row, both owners and **which side survives**. Ticked → `apply(..., $mergeConflictsBy)` collapses each pair through the SAME machinery as the manual review modal — `LeadRepository::detectMergePair` → `pickMergeSurvivor` (richer by `accountRecordScore`; tie → the older account) → `createMergeRequest` → `mergeVerifiedPair` — so every bulk merge leaves the standard `verified_identity_pairs` audit trail (`reviewed_by` = the importing admin, `note = 'auto: import (conflict tickbox)'` — since 2026-07-23 every merge stamps a provenance note) and the survivor keeps everything + both keys. **Customers only:** a pair with a staff/privileged side is never bulk-merged (`pickMergeSurvivor` returns null; the row stays a skipped conflict, tagged "staff — never auto-merged") — those belong in the [Merge Requests queue](/docs/modules_handbook/manage/people/merge-requests/readMe.md), where a human confirms and the staff side is forced to survive. A merged row then continues as a normal match on the survivor (backfill, account manager, profile extras) and reports the new apply outcome **`merged`** (flash: "N duplicate pairs merged into one account"); the post-merge changed-contact gate is skipped (the survivor legitimately keeps its own keys). The conflict CSV download gains `Email owner / Phone owner / Survivor if merged` columns.
  - **An import NEVER edits an existing lead's email/phone — by design.** There is no overwrite path at all: bulk CSV is a blunt instrument, and one typo'd cell would silently corrupt a real customer's identity key, while enrolling the wrong person is trivially reversible. Fixing a genuinely changed detail is a per-person edit on the lead itself; the `Needs review` **CSV download is the worklist** for exactly that. The junk floor (a phone cell must be ≥ 7 digits, the same rule `has_identity` uses) keeps a placeholder like `"123"` from even being reported as a change.
  - **Phone equality has two strengths, on purpose** (`PhoneNumber`): `sameNumber()` (strict, canonical E.164) **binds** an identity — `matchUserByPhone` keeps it, because a loose shape is not globally unique and an unverified match could bind a *different* person. `sameNumberTolerant()` (canonical **or** any shared `candidates()` shape) is for **fail-closed** decisions — `phoneOwnedByAnother()` (refuse more often) and the changed-contact detector (flag less often). The strict form cannot round-trip a **bare-national** stored phone (`123456789` vs `60123456789`), which `candidates()` explicitly lists and the `whereIn` *does* fetch; verifying the ownership gate with it would discard a real owner, report the number free, and let one real number land on two profiles (`backfillContact` shares that gate). The confirm button states exactly what will happen (e.g. **"Create 148 leads"** / enrol mode **"Enrol 108 members"**). When the preview leaves **nothing to action** (every row already exists / all skipped) the button is **disabled** and a note explains why ("Every person in this file is already a lead in the system…") — no pointless write. Otherwise Confirm → `import` → `BulkMemberImportAction::apply` and a flash of `{created} created, {existing} existed, {skipped} skipped`.
  - **Expected columns** (auto-detected by alias via `MemberRowMapper`, any order/name): **email** (email/e_mail/mail/email_address), **phone** (phone/mobile/hp/handphone/contact/whatsapp/…), **name** (name/full_name/customer). A **header row is required**; each row needs at least an **email OR a phone** (≥7 digits). Phone accepts any format (symbols stripped) and matches an existing person **tolerantly** across shapes (`0123…` = `+60…` = `60…`) via the identity gate above — so a re-import never duplicates. Staff/admins are never turned into a lead. (The members importer reuses the SAME modal with `mode='enrol'` and two extra columns — `amount`, `paid_at`.)
  - **Optional profile columns (leads mode only, added 2026-07-21)** — a legacy CRM export often already carries more than identity, so the leads importer also accepts: **created_at** (aka `registered_at` / `signup_date`) → backdates the lead via `LeadRepository::backdateCreation()`, which moves its `lead_funnels.registered_at` with it so the two can never disagree. ⚠️ It applies **only to a lead the row just created** (`LinkResult::personExisted === false`) — re-dating someone already on record would rewrite real history, and a re-import would do it again. A **future** date is rejected outright (it would pin the row to the top of every date sort forever), and `users.created_at` is deliberately left at the real import time. Without this column a file spanning years collapses onto the day of the upload, which is what the Leads index "Created At" column and its sort read (`LeadsController` row mapper). Also: **property_own** (aka `properties_owned` / `property_count`) → `leads.properties_owned`; and an intelligence block → `lead_enrichments`: **intel_occupation** → `estimated_occupation`, **intel_income** (`low`/`mid`/`mid_high`/`high`) → `income_bracket`, **intel_summary** → `profile_summary`, **intel_hometown** → `hometown_note`, **intel_ip_location** → split into `geo_city` / `geo_region` / `geo_isp` (+ `geo_country`/`_code`) by `App\Support\MemberImport\ImportedLocation`. Unlike the canonical columns these are surfaced by `MemberRowMapper::detect()` **only when the file actually has them** (`EXTRA_FIELDS`), so the members import is not cluttered with columns it has no use for. Three rules make them safe: every write is **fill-blank-only** (a re-import, or a file that disagrees with a hand-correction or a live enrichment run, never overwrites), a cell reading `Unknown`/`N/A`/`-` is stored as **NULL** rather than as that word, and `0` properties is kept as `0` (a real answer) while a missing cell stays `NULL` (never asked). `ImportedLocation` finds the state by matching each fragment against the **known Malaysian state list** (`enrichment.income.state_median`) instead of trusting a separator, so the half-dozen export formats (`City, State (ISP)` / `via ISP` / `; ISP: X` / `- ISP`) all resolve — and resolving the state earns the free **DOSM area-income prior** the live pipeline would have derived. `geo_ip` is deliberately left NULL: that blank is what marks a location as imported rather than IP-derived. No `status` is written, so the profile honestly stays *Pending* until enrichment actually runs.
  - **Scale note (added 2026-07-21 after a production timeout).** Both preview and apply run **synchronously in the HTTP request**, row by row (~35–48 queries per row; a ticked merge adds a full account-merge per conflict pair). PHP's default 30-second budget died mid-apply on a 500-row production chunk, leaving a partial (but safe — the import is idempotent, a re-upload reports the finished rows as `existing`) write. `ParsesMemberImport::parseImport` therefore lifts `set_time_limit(0)` / `memory_limit` for import requests. The **web-server proxy timeout still applies** (nginx defaults ~60s) — it cuts the *response*, not the PHP run — so big files should still go up in **chunks of a few hundred rows** — or use the CLI twin: **`php artisan leads:import {file-or-folder}`** ([app/Console/Commands/ImportLeadsCsv.php](/app/Console/Commands/ImportLeadsCsv.php)) runs the SAME parse → `BulkMemberImportAction` pipeline with no time limit; a folder is processed file-by-file in name order, `--dry-run` previews, `--merge-conflicts --reviewer=<admin email>` enables the conflict auto-merge (the reviewer is stamped on `verified_identity_pairs`), `--yes` skips the prompt. Idempotent — a folder may safely contain chunks already imported through the browser.
  - **Assign the whole batch to one account manager (optional).** The import dialog carries a single **account-manager** picker (`assignableAdmins` prop, searchable) — the chosen admin becomes the account manager for every imported lead that **does not already have one** (existing leads with a manager are **left untouched**, so a re-import never silently reassigns). `LeadsController@import` resolves the uuid through `assignableManagerQuery` (in-scope active staff only) and threads the id into `BulkMemberImportAction::apply(..., $accountManagerId)`, which fill-blank-assigns via `LeadRepository::assign()`. The membership **enrol**-mode import does **not** show this picker (managers are a lead concept, set once).
- **Account manager (one per lead).** Each lead has a **single** account manager — a manage-portal staff member stored in `leads.assigned_admin_id` (`Lead::assignedAdmin()`, nullable = unassigned). It is the one person who looks after that lead across everything, **including all their memberships** (the [Members](/docs/modules_handbook/manage/membership/members/readMe.md) module reads it **read-only**). Set it from the **identity header** on the Lead Show page, the **Account Manager** column / row action on the Leads list, or — since 2026-09-03 — the **Change** button on the shared `LeadDetailModal`'s account-manager strip (so a physical session's door can reassign an arrival without leaving the roster) — all three open the shared `AssignLeadManagerModal` (single-select) which posts `assignee` (a staff uuid, or empty to unassign) to `LeadsController@assign` (route `manage.leads.assign`, `AssignRequest`); `LeadRepository::assign()` writes it. In the modal the component runs in its `host="modal"` mode (the same in-place Inertia transport as `LeadFormModal`: `preserveState` + `keepOpenDuringVisit()`, so the host page's roster refreshes underneath while the modal stays open, then `@saved` re-pulls `?section=lead`); its options are `quick()`'s `assignable_admins` (the same `assignableAdmins()` pool, sent only to a `manage-leads` holder). Assignment is **group-scoped**: a group actor may only reassign leads their own group generated, and only to an in-group (or platform) assignee — mirrored by the `can_assign` flag `transform()` emits alongside `assigned_admin` (`{uuid,name}|null`). The assignable pool is the shared `ResolvesAssignableManagers` (active manage-role staff in the actor's group). This same `assigned_admin_id` is the lead-ownership key `LeadVisibility` uses to scope who can see the lead. (Before 2026-07-18 account managers were a many-to-many on each membership subscription; that pivot was collapsed into this single lead-level value.)
- **Convert to member:** a lead's detail page can enrol the account into a membership tier (see the [Members](/docs/modules_handbook/manage/membership/members/readMe.md) module); lead status is left unchanged.
- **Show page (`Show.vue`) tabs** surface the lead's whole footprint via `ShowTabs`, in six groups — **Intelligence**, **Sales**, **Channel**, **Property Portal**, **Activity**, **Discussion** (plus **Physical Events**, which appears only when the lead has one). Three of them nest their own strip on their own query param: Sales **`?stab=`**, Channel **`?ctab=`**, Property Portal **`?ptab=`** — **each nested strip MUST be given a unique `param-name`**, or it silently fights the outer `?tab=` over the same key. **Intelligence** is the identity/enrichment report (`AccountTab`) followed by an **Attribution** section (`AttributionTab`, which also lists the lead's **Messenger captures**, fed by the `messengerCaptures` prop) — it is one tab, not two, because attribution is context you read next to the person rather than a place you navigate to.
- **⚠️ The tab tree is DEFINED ONCE, in [`composables/useLeadTabs.js`](/resources/js/composables/useLeadTabs.js)** — every label, order, permission gate and count badge, plus the legacy-key map. **Two** surfaces render a lead and must show the same thing: this page (fed by Inertia props from `show()`) and the shared read-only **`LeadDetailModal`** (fed by the JSON of `GET /manage/leads/{uuid}/quick`). They used to each declare their own list, and **drifted** — the modal was left on the flat, pre-regrouping set (Account / Membership / Attribution / Pipeline / Appointments side by side) long after the page had regrouped them, and never gained Property Match or Rental Estimate at all, even though `quick()` was already sending both. Each host now passes the composable one `get(key, fallback)` reader over whatever payload it holds; **that reader is the only difference between them.** Locked by [useLeadTabs.test.js](/resources/js/composables/useLeadTabs.test.js), which fails if either file is missing a `#tab-{key}` body for any key in the tree, or reintroduces a hand-rolled list.
- **The "Sales" tab groups everything about closing this person** (`?stab=`): **Pipeline** (per-project Caller→Closer→Follow-Up engagements — see [Engagements & Bookings](/docs/modules_handbook/manage/engagement/readMe.md)), **Membership**, **Appointments**, **Property Match**, **Rental Estimate**, and **CTA** (`CtaTab`, the lead's **WhatsApp CTA touches** — the wa.me links they responded to, fed by `whatsappCtas`; see [WhatsApp CTA Links](/docs/modules_handbook/manage/messages/whatsapp/cta_link.md), and previously a Channel sub-tab).
- **The "Property Portal" tab groups everything this person did inside the member portal** (2026-07-19 — it was called **"Property"**, and **Concierge** was a separate top-level tab until it was folded in). It nests its own `ShowTabs` on **`?ptab=`**, five sub-tabs in order: **Wealth Planning**, **Property Analyses**, **AI Conversations**, **AI Debates**, **Concierge** (the property-concierge requests). The AI pair is the **read-only history of the member's portal AI usage** — **AI Conversations** (the full chat thread) and **AI Debates** (the whole panel board + verdict); both are fed as plain props from `show()` (eager-loaded, no N+1) and rendered with the same safe markdown renderer as the portal, and admins only **view** (no streaming / no writes). See [AI Conversations](/docs/modules_handbook/main/ai-conversations/readMe.md) + [AI Debate](/docs/modules_handbook/main/ai-debate/readMe.md).
  - **Activity is a TOP-LEVEL tab, sitting beside this group rather than inside it** (the FULL activity trail — everything that happened to this lead, not only what the lead did themselves; see [ActivityLogger](/docs/modules_handbook/shared/activity-log/readMe.md)). It was briefly a sixth sub-tab here, with the parent badge written to exclude it: the trail **grows forever** (`activityTotal` is the true server-side total, not the capped slice the tab renders), so folding it into the group badge would swamp every other sub-count and turn the number into noise. A sub-tab that has to be excluded from its own group's badge is not in the right group — so the Property Portal badge now simply sums its five children, and Activity shows its own real total on its own tab.
    - **Summary cards + category filter + full-history search.** Above the timeline: last-active / total-actions / projects-viewed / lessons-completed cards (`activitySummary`, computed over the WHOLE trail, not the capped slice) and a category-chip strip that doubles as the primary filter. A search box, a date range and a type multi-select (behind "More filters") round it out, plus a "Load more" that appends rather than paging. Every filter/search/date/load-more interaction round-trips to `GET {id}/activity` ([`LeadActivityController`](/app/Http/Controllers/Manage/Leads/LeadActivityController.php)) — client-side filtering over the capped slice is explicitly forbidden (it would silently filter a slice and present it as the whole trail). The read-only `LeadDetailModal` gets the summary cards only, no filter bar, no second fetch path.
  - **Outbound deep-links must carry both params.** Anything linking into a sub-tab needs `?tab=portal&ptab=<key>` — sending the sub-key as `?tab=` makes `ShowTabs` fall back to the first tab and lands the user on *Intelligence* (the legacy map below rescues the handful of keys it knows, not every key). Current callers: `LeadsController::analysis()`'s `backUrl` (`ptab=analyses`), `Portal/WealthPlans/Index.vue` (`ptab=wealth`), `Portal/Conversations/Index.vue` (`ptab=conversations`), and `Portal/Concierge/Index.vue` + `Show.vue` (`ptab=concierge`).
- **The "Channel" tab groups every way we have talked to this person** (2026-07-17). It nests its own `ShowTabs` on **`?ctab=`**, in order: **WhatsApp** + **Messenger** (the read-only threads, below), **Zoom** (`ZoomTab`), **Phone Call** (`PhoneCallTab`, the phone-call artifacts — see [Phone Call](/docs/modules_handbook/manage/call-history/readMe.md)), **AI Caller** (`Channel/AiCallerTab.vue`, 2026-08-24 — the Retell voice agent's calls to this lead with transcript + the WhatsApp follow-up's delivery ladder; fed by the eager `aiVoiceCalls` prop from the shared `Src\VoiceAgent\Support\AiCallPresenter`, gated `view-calls`, same rows in `show()` and `quick()` — see [voice-agent](/docs/modules_handbook/shared/voice-agent/readMe.md)), and **Showroom** (`ShowroomF2fTab`, the face-to-face recordings). WhatsApp, Messenger and AI Caller are **permission-gated** (`view-whatsapp` / `view-messenger` / `view-calls`) and are simply absent — not empty — without it. The parent badge sums the sub-counts, mirroring the Property Portal tab.
- **Channel > WhatsApp / Messenger — READ-ONLY threads (2026-07-17).** Each shows this person's conversations (a mini list on the left when they have more than one, the thread on the right) with an **"Open in Inbox"** link — **there is no composer here on purpose**: replying, the 24-hour window, templates and AI all stay in the module's own inbox, so this surface has no writes at all (no Repository, no Form Request). Fed by **`Src\Whatsapp\Support\LeadConversationPresenter::forLead()`** / **`Src\Messenger\Support\LeadConversationPresenter::forLead()`** as **`Inertia::optional`** props (`whatsappThreads` / `messengerThreads`) — the closure does **not** run on a normal page load, only on the partial reload the lazy-mounted sub-tab fires on open, so a lead page that never opens the tab pays nothing. Messages render through the shared **`resources/js/Components/Conversation/`** components, the same shape (`MessagePresenter`) the inbox uses.
  - **⚠️ The partial reload re-runs the WHOLE `show()`** (~11 queries) — Inertia only skips the *other* optional props, and the rest are eager arrays. So the tabs deliberately have **no poll** (unlike the inbox's 6s one): loading is on mount plus a manual Refresh button. Making `show()`'s heavy props optional is the real fix, and is not done yet.
  - **Two security controls live in these presenters, and neither is optional:** (1) **only `CHAT_INDIVIDUAL`** — a WhatsApp **group** thread is shared by many people and belongs to no single lead, and **`LeadVisibility` does NOT exclude it** (the group conversation still resolves through `contact.user.lead`, so its check passes), making the explicit `chat_type` filter the *only* thing preventing a whole group's chat from being shown on one member's lead page; (2) the lead↔contact link is **`contact.user_id`, matched STRICTLY** — never a tolerant phone match. Linking is [ContactLinker](/docs/modules_handbook/manage/messages/whatsapp/group.md)'s job (it verifies canonical E.164 first); a loose match here would hang a stranger's entire history off this lead. If a thread is missing, fix the **link**, not the query. Sandbox channels are excluded too. Locked by [LeadChannelTabTest](/tests/Feature/Lead/LeadChannelTabTest.php) — whose group-exclusion case is mutation-verified (removing the filter makes it fail).
  - **Legacy deep-links are seeded, not just rewritten.** The regroup swallowed former **top-level** tab keys — `account` / `attribution` → **Intelligence**, `membership` / `pipeline` / `appointments` / `cta` → **Sales**, `calls` / `zoom` → **Channel**, `property` / `concierge` → **Property Portal** — and `ShowTabs` **silently falls back to the first tab** on a key it does not recognise, so an old `?tab=calls` bookmark would land on *Intelligence* with no error. One **`LEGACY_TAB_MAP`** (in `useLeadTabs.js`) maps each old key to `[tab, subParam?, subKey?]`, and `remapLeadTabs()` applies it; `Show.vue` reads the URL's `?tab=` through it and seeds **both** strips via `v-model`. Seeding is what does the work: **`ShowTabs` resolves its initial tab from Inertia's `page.url`, which `history.replaceState` does NOT update** — so rewriting the address bar alone is inert. The `replaceState` that follows is purely cosmetic (so the URL copied from the page is the current one). Two details: legacy **`?tab=property` seeds an empty sub-key on purpose** — it already carried its own `?ptab=`, so the inner strip resolves that param itself — and an explicitly requested sub-key always **wins** over the one the map would supply. The **modal runs the same map** over the `{ tab, stab, ctab, ptab }` an opener passes to `openLead()`, so a caller still asking for `{ tab: 'concierge' }` lands where it meant to. Seeding a sub-key that does not exist for this lead is safe: `ShowTabs` watches its own tab list and falls back to the first entry when the active key disappears.
- **The "Discussion" tab is the internal admin workspace for the lead** — never surfaced to the portal user. Left: the **general comment feed** + **topic threads** (`DiscussionTab.vue`; **any admin may edit or delete any comment/topic** — the owner-only guards were dropped 2026-08-11: it is a shared team workspace, and authorship still shows via `created_by` / the "You" badge). Lead-shaped rosters deep-link INTO it through ONE shared component, [`Components/LeadDiscussionButton.vue`](/resources/js/Components/LeadDiscussionButton.vue) (2026-08-12 — the engagement tables, the Property Match view and the VSL funnel roster all mount it, and it carries a violet **comment-count badge** fed by the shared `Concerns\BuildsDiscussionCounts` trait, one grouped query per page keyed `(lead, topic-title)`). It calls `openLeadDiscussion(uuid, title)` (`useLeadModal.js`), which **find-or-creates a topic named for the source** — the project's name, the literal `Property Match`, or a VSL funnel's **linked project** (`event_funnels.project_id`, else the funnel's own name — so a marketing remark and a sales remark about one deal share ONE thread) — via `POST …/discussion/topics/ensure` (idempotent by (lead, title); a deleted topic stays deleted, a fresh one is minted), then opens the modal landed on that thread (`requestedTabs.topic` → `DiscussionTab`'s `focusTopicUuid`). The ensure runs BEFORE `openLead` on purpose: the modal fetches its payload on open, and a topic created after that fetch wouldn't be in it. Right: a **sticky Action Items rail** (`ActionItemsPanel.vue`, 2026-08-03) — an admin writes what needs doing, attaches **images / videos** (validated in the Form Request per the [Media](/docs/modules_handbook/shared/media/readMe.md) handbook, stored via `MediaService` under collection `action-item`, rendered inline off short-lived signed URLs) and assigns it to **one or more admins**. Each **newly** assigned admin is alerted on their own phone through the shared [Notify](/docs/modules_handbook/shared/notify/readMe.md) service's **addressed path** (`Notifier::sendToUsers`, event `leads.action_item_assigned` — personal destinations only, never shared groups; the actor is never self-notified; re-saving an item does not re-buzz existing assignees). ⚠️ Only holders of a **manage role** can be assigned at all (`resolveAssigneeIds`), so a role-less account is dropped before the alert is even considered. Pinned by `tests/Feature/Leads/ActionItemNotifyTest.php` — every rule here fails SILENTLY (a colleague simply never hears about their task), which is why it is tested rather than trusted. Anyone who can view the lead may tick an item **done** / reopen it (it is a shared checklist); edit stays with the author, delete with the author or a super-admin. Both columns ride the one `discussion` payload (`comments` / `topics` / `action_items` / `admins` — the assignee picker options), so the modal's `?section=discussion` refresh keeps the panel live too — and since 2026-08-12 that refresh ALSO fires a bracketed `router.reload()` (`keepOpenDuringVisit`, `preserveState`) so the HOST table's discussion badges pick up the comment just posted or deleted; the axios path cannot patch that number itself, because it lives in the host's props, not the modal's — and deleting an item hard-deletes its attachment files first (`MediaService::delete`, outside the transaction) so no bucket object is stranded.

### Deleting ONE lead — a hard delete, now guarded (2026-08-06)

`leads` has **no soft delete**, and the `deleting` cascade (`Lead::booted()`) **force-deletes** the person's subscriptions, comments, topics, engagements and bookings. There is no undo, and until this date there was no guard and no audit trail either.

It had already cost money. An **ACTIVE MYR 100 Stripe payment** (`purchase_histories` #273 → lead 12801, `pi_3TqZbvEWKNIsKNuK0HBAoy0V`, paid 7 Jul 2026) points at a lead that no longer exists — and because every revenue query joins **through** the lead, that payment is invisible on every screen, *including to a super-admin*. Twelve rows across five tables are orphaned the same way, and nothing recorded who pressed the button.

Three changes, each pinned (and mutation-verified) by [LeadDeletionGuardTest](/tests/Feature/Lead/LeadDeletionGuardTest.php):

- **`destroy()` REFUSES rather than warns.** A lead carrying a `purchase_histories`, `member_subscriptions`, `bookings` or `engagements` row cannot be deleted, and the flash names what is in the way. ⚠️ The counts are raw and therefore **include soft-deleted rows** — a refuse-gate must fail closed, and a binned payment orphans exactly as a live one does. Side effect worth knowing: any lead that has ever entered the sales pipeline is now undeletable from this screen.
- **Every deletion is logged** — actor, lead uuid, account email — to the **application log**, not the activity trail. The trail is scoped to a lead, so it would be destroyed by the very action it is meant to record.
- **The cascade no longer leaks.** `$lead->engagements()->get()` silently skipped **already-trashed** engagements (Engagement is soft-deletable), so they and their bookings survived pointing at a deleted lead — which is precisely how engagement #207 / booking #195 became orphans. Now `withTrashed()`. At the time of the fix, **22 trashed engagements and 32 trashed bookings** were sitting on live leads, each an orphan-in-waiting.

**Finding what already leaked:** `php artisan leads:orphans` ([ReportOrphanedLeadRecords](/app/Console/Commands/ReportOrphanedLeadRecords.php)) reports every row whose `lead_id` resolves to nothing, money tables first. Read-only by design — repairing a payment means identifying the real buyer at the gateway, and guessing would file money under the wrong person. ⚠️ It scopes the table scan to the **current database**: `Schema::getTableListing()` returns schema-qualified names for *every* database the connection can see (1,608 on a dev machine), so stripping the prefix without filtering both triple-counts and reads schemas that are none of our business.

> **Why not just soft-delete `Lead`?** Investigated 2026-08-06 and deliberately rejected. Laravel fires `deleting` on a **soft** delete too, so the cascade above would still force-delete the children — you would restore an empty shell. Worse, `EnsureUserIsMainUser` treats a hidden lead as *absent* and calls `ensureLeadForPortal`, whose `firstOrCreate` is equally blind to the trashed row: it would try to INSERT a second lead and hit `leads_user_id_unique` — a permanent 500 for that customer, on every page, unfixable from the admin screen. The same `firstOrCreate` pattern guards six lead-creation sites including the Stripe webhook and passwordless sign-in. Separately, Malaysian law grants no right to erasure (the 2024 amendment added portability, a DPO duty and breach notification, not deletion); s.38 requires *ceasing to process*, for which the existing `User::STATUS_MERGED` shape — retire the account, free its unique keys — is the working precedent.

## Danger zone — "Delete All Leads" (live on every environment again, 2026-09-04)

> **The environment gate is gone — read why before you touch this.** The tool was confined to `APP_ENV=local` on 2026-07-15 for ONE reason: it deleted **staff portal leads**. Only the admin's `users` row was protected, never their `leads` row, and because `EnsureUserIsMainUser` lazily mints a replacement lead, the admin walked back into an **empty** user portal — no wealth plans, no AI credits, no membership — with nothing having failed and nothing in a log. Everything a portal user owns hangs off `lead_id` (see the [identity handbook](/docs/modules_handbook/shared/user-lead-admin-login-register-merge/readMe.md) — *Admin + Lead are HATS*), so losing the lead row loses the data.
>
> **That is fixed at the source: the purge now skips a staff lead WHOLE** — the row itself, not just the account behind it (`LeadRepository::excludeProtectedLeads`, applied in `purgeAll()`'s chunk query *and* re-checked per row in `purgeChunk()`). With the reason for the lockdown gone, the environment gate went with it on 2026-09-04; the route, the controller check and the UI prop are now all **super-admin**, which is what they always should have been. **Do not restore an env check to make this "safer"** — it would only hide the tool, not the bug it was standing in for.

A **super-admin-only** button on the Leads index that permanently wipes every **customer** lead, everything hanging off those leads, and the **customer `users` accounts** behind them — the "refresh the client's production data" reset. **Colleagues are skipped whole**, so an admin keeps user-portal access and every record attached to their lead.

**Gates (three, independent):** the route's `role:super-admin` middleware · `abort_unless($request->user()->isSuperAdmin(), 403)` in the controller · a typed confirmation phrase (`DELETE ALL LEADS`) validated **server-side** by `PurgeAllRequest` (the modal disables the button, but the client is never the gate). The same super-admin test ships as the `purgeEnabled` Inertia prop, so the button is never shown to someone the endpoint would reject. The work is **queued** (`PurgeAllLeadsJob`, `$tries = 1`) because it walks ~50 tables and deletes the stored files.

### Who is skipped — the one thing to get right
A **staff person is skipped whole: their `leads` row AND their `users` row.** This is the guarantee that lets the tool run on production at all, so it is enforced twice — `purgeAll()` constrains its chunk query with `excludeProtectedLeads()`, and `purgeChunk()` re-rejects any protected row that still reaches it.

A lead is protected when **any** of three signals fires:
- **`leads.is_staff`** — the flag `UserRepository::ensureStaffLead` stamps when an admin is created or promoted. It is the column that literally means "this lead is a colleague";
- its `user_id` has an **`admins` row — including a soft-deleted one** (an ex-admin's account and data must survive); or
- its `user_id` holds **any role other than `member` / `non-member`**.

All three are needed. `ContactLinker` auto-creates a CRM lead for *any* reachable inbound 1:1 WhatsApp contact, so an admin who messages the company number from their own phone ends up owning a `leads` row that `ensureStaffLead` never stamped — the role and `admins` signals catch that one. The `is_staff` signal is the one that still holds if an `admins` row is ever hard-deleted while the person keeps portal data.

**A lead with a NULL `user_id` is purgeable, and is matched explicitly.** No code path creates one, but SQL's `NOT IN` yields NULL rather than TRUE for a NULL column, so leaving it to three-valued logic would silently *exclude* such a row from the purge — the opposite of what the filter is for.

`leads.assigned_admin_id` is an *admin's* `users.id` and is never a deletion key — neither are `created_by` / `updated_by` / `deleted_by` (blame columns) or `admin_id` (which is `admins.id`, not `users.id`).

> **Why the lead row matters as much as the account.** Every portal-owned record is keyed on `lead_id`, never `user_id`. An admin whose lead was deleted still signed in fine, because `EnsureUserIsMainUser` self-heals a missing lead via `ensureLeadForPortal` — and that is exactly what made the old bug invisible: they got a *new, empty* lead instead of an error. Deleting a staff lead does not lock a colleague out; it quietly empties their portal.

### How it works
`LeadRepository::purgeAll()` chunks by lead (200 at a time) and, per chunk: resolves every dependent id set **first** (nothing is findable once its parent is gone) → deletes the **stored files** → deletes the **rows** in one transaction.

- **It deliberately does NOT use the `Lead` / `User` deleting hooks.** Those cascade ~12 tables out of ~50, and since the schema carries **no foreign-key constraints** (GUIDELINES §7) the rest would be orphaned *silently*. Every table is deleted explicitly, children before parents. (The hooks are untouched — the single-lead `destroy()` still uses them.)
- **Raw `DB::table()` queries throughout**, so neither a soft-delete scope nor a **global scope** can hide a row: `CallRecording` has a `NotIgnoredScope`, so an Eloquent delete would silently skip every `is_ignored` recording. A soft delete would also just leave the customer's PII sitting in the table.
- **Files go through `MediaService::delete()`** — the only path that removes the **GCS object** as well as the `media` row. Storage I/O happens **outside** the transaction (a bucket delete cannot be rolled back) and a failure is logged without aborting the purge: a stranded object is recoverable, a half-purged database is not. Concierge photos live on the local `public` disk and are deleted directly.

### What is deleted
`leads` + the customer's `users` / `user_profiles` / `addresses` / role assignments, and: `lead_funnels`, `lead_enrichments`, `lead_ai_credits`, `lead_comments`, `lead_topics`, `ai_credentials` (lead-scoped), `member_subscriptions`, `purchase_histories`, `bookings`, `engagements`, `appointments`, `event_registrations`, `zoom_meetings`, `zoom_recordings`, `zoom_webinar_attendances`, `zoom_webinar_responses`, `call_recordings`, `f2f_recordings`, `agent_call_events`, `wealth_plans`, `wealth_whopay_reports`, `property_analyses`, `concierge_requests` (+ `_events`, `_photos`), `lms_lesson_progress`, `ai_conversations` (+ messages), `ai_debates` (+ responses), `ai_requests` (by `lead_id` **or** by a matching `subject` morph — the WhatsApp/recording pipelines log with `lead_id` NULL), `funnel_whatsapp_sends`, `whatsapp_cta_captures`, `messenger_captures`, the leads' `whatsapp_contacts` / `messenger_contacts` and their conversations, messages, attachments, consents and tag pivots, plus `flg_leads` (its `meta_leadgen_id` is UNIQUE — a survivor would silently block that Meta lead from ever being re-ingested).

### What survives
**Staff leads and everything hanging off them** (wealth plans, AI conversations & credits, concierge requests, membership, property analyses — every `lead_id`-owned row of a colleague) · admin accounts + `admins` rows + roles/permissions · WhatsApp & Messenger **channels**, templates, flows, broadcasts, tags, segments · funnels, events, webinars · memberships, courses, projects · **global** `ai_credentials` (`lead_id IS NULL`) and admin/system `ai_requests`. Three tables are **unlinked, not deleted** (admin-owned rows that merely reference a lead): `activities.lead_id` → NULL, `flg_owner_listing_rows.lead_id` → NULL, `whatsapp_group_participants.contact_id` → NULL (the group roster is channel infra and is re-upserted from the Bridge anyway). Group messages sent by a purged contact keep their row with `sender_contact_id` → NULL.

### Accepted side effects
- **Zoom is not called.** Meetings and registrants already created on Zoom stay there: a purged lead's join URL keeps working and Zoom may still send its own reminder emails. (The repo has no cancel-registrant call.)
- `zoom_recordings` is a metadata cache only — the recordings themselves live in the Zoom account.
- Queued jobs still holding a reference to a deleted lead/user will fail into `failed_jobs`. Run the purge in a quiet window.
- `LeadVisibility` / `group_id` are **deliberately not applied** — this is an all-leads wipe gated on super-admin. A group-scoped purge would leave inconsistent cross-group children and is a separate feature.

### To remove this feature
Delete `app/Jobs/Lead/PurgeAllLeadsJob.php`, `app/Http/Requests/Manage/Leads/PurgeAllRequest.php`, `tests/Feature/Lead/PurgeAllLeadsTest.php`, `LeadsController::purgeAll()`, the `manage.leads.purge-all` route, the purge block + "Delete All Leads" button in `Index.vue`, the `purgeEnabled` / `purgeableLeads` props, the purge methods on `LeadRepository` (`purgeAll`, `purgeableLeadCount`, `protectedUserIds`, `excludeProtectedLeads`, `purgeChunk`, `purgeRows`, `purgeStorage`, `withMergedShells`, `purgeOrphanConsents`), and this section. (`ConfirmModal`'s `confirmDisabled` prop is generic — keep it.)

---

## Related files

**Backend — Models**
- [src/Lead/Lead.php](/src/Lead/Lead.php) — the person: `user_id` + `flg_lead_id` + `status`; `STATUSES`; `belongsTo(User)`, `hasMany(leadFunnels)`, `belongsToMany(funnels)`.
- [src/Lead/LeadFunnel.php](/src/Lead/LeadFunnel.php) — a registration: `lead_id` + `event_funnel_id` + `registered_at` + all attribution (utm_*, `fbclid`, **`fbp` / `fbc`**, campaign/adset/ad ids, placement, referrer, landing_url, ip, user_agent); `SOURCES` + source accessors; `belongsTo(Lead)`, `belongsTo(EventFunnel)`.
- [src/Lead/LeadActionItem.php](/src/Lead/LeadActionItem.php) — a Discussion-tab action item: `STATUS_OPEN` / `STATUS_DONE` + `STATUSES`; `assignees()` (belongsToMany users via `lead_action_item_assignees`), `media()` morph (collection `action-item`), `toShowArray()` (permission flags + signed attachment URLs).

**Backend — Linking (the unified shell)** — documented in [Lead Linking](/docs/modules_handbook/shared/lead-linking/readMe.md)
- [src/Lead/Services/LeadLinker.php](/src/Lead/Services/LeadLinker.php) + [src/Lead/Support/](/src/Lead/Support/) (`LinkRequest` / `LinkResult` / `LeadAttribution`) + [tests/Feature/Lead/LeadLinkerTest.php](/tests/Feature/Lead/LeadLinkerTest.php).

**Backend — Repositories**
- [src/Lead/Repositories/LeadRepository.php](/src/Lead/Repositories/LeadRepository.php) — the **identity gate**: `firstOrCreateForIdentity()` / `resolveUserByIdentity()` / `matchUserByPhone()` / `phoneOwnedByAnother()` / `backfillContact()`; `firstOrCreateForPhone()`/`ForEmail()` delegate to it; `create()` (create-only status), `firstOrCreateForUser()`, `assign()` / `unassign()`, and `purgeAll()` + `purgeableLeadCount()` (see *Danger zone* above — the purge skips staff leads whole).
- [src/Common/Support/PhoneNumber.php](/src/Common/Support/PhoneNumber.php) — phone tolerance: `digits()` (storage, keeps trunk-0), `e164()` / `canonicalDigits()` / `candidates()` / `sameNumber()`. `Src\Whatsapp\Services\PhoneNormalizer` + `Src\Call\Support\LeadMatcher` build on it.
- [src/Lead/Repositories/LeadFunnelRepository.php](/src/Lead/Repositories/LeadFunnelRepository.php) — `attach($lead, $funnelId, $input)` — idempotent per `(lead, funnel)`, captures first-touch attribution (also exposed via `Src\Lead\Facades\LeadFunnelRepository`).

**Backend — Quality-state resolver + export**
- [src/Lead/Support/LeadQuality.php](/src/Lead/Support/LeadQuality.php) — the shared Agent/Fake
  resolver: `agentState()`/`fakeState()`/`agentSource()`/`fakeSource()`, `AGENT_STATES`/
  `FAKE_STATES` metadata, `agentLabel()`/`fakeLabel()` (the "Fake?" hedge), `agentStateSql()`/
  `fakeStateSql(string $table = 'leads')`, `agentEvidence()`/`fakeEvidence()`.
- [app/Exports/LeadsExport.php](/app/Exports/LeadsExport.php) — the Excel/CSV download; `Agent
  verdict`/`Fake verdict` resolved via `LeadQuality`.

**Backend — Controller**
- [app/Http/Controllers/Manage/Leads/LeadsController.php](/app/Http/Controllers/Manage/Leads/LeadsController.php) — search / index / **export** (⚠️ hands `LeadsExport` the BUILDER, never `->get()`, and appends a `leads.id` tiebreaker: the export is `FromQuery` so the writer chunks it — see the [Export handbook](/docs/modules_handbook/shared/export/readMe.md) for the 406 MB → 158 MB measurement behind that) / store / importPreview / import / show / showAnalysis / enrich / convert / update / assign / destroy / purgeAll (uses `UserRepository` for contact edits; `store` links through `LeadLinker`). Also `buildActivityRows()` (first-paint capped slice) + `buildActivitySummary()` (the Activity tab's summary cards, over the whole trail) on both `show()` and `quick()`.
- [app/Http/Controllers/Manage/Leads/LeadActivityController.php](/app/Http/Controllers/Manage/Leads/LeadActivityController.php) — `GET {id}/activity`: the Activity tab's filtered, paginated trail (category / type / search / date range), gated by the same `LeadVisibility` check as `show()` / `quick()`. Shares its row mapper with `LeadsController::buildActivityRows()` via [app/Http/Controllers/Concerns/BuildsActivityRows.php](/app/Http/Controllers/Concerns/BuildsActivityRows.php) — see the [ActivityLogger handbook](/docs/modules_handbook/shared/activity-log/readMe.md) for the shape.
- [app/Http/Controllers/Manage/Leads/ActionItemsController.php](/app/Http/Controllers/Manage/Leads/ActionItemsController.php) — store / update / destroy / done / reopen for the Discussion tab's Action Items rail (`manage.leads.action-items.*`): resolves assignee uuids server-side (manage roles only), stores attachments via `MediaService`, fires the addressed `leads.action_item_assigned` notify for **newly** added assignees, and hard-deletes attachment files before the row on destroy. Uses [src/Lead/Repositories/LeadActionItemRepository.php](/src/Lead/Repositories/LeadActionItemRepository.php) for every DB write.

**Backend — Middleware (app-wide, listed here because the Discussion tab is what exposed it)**
- [app/Http/Middleware/PreventRedirectMethodReplay.php](/app/Http/Middleware/PreventRedirectMethodReplay.php) — answers every PUT / PATCH / DELETE redirect with **303 See Other** instead of 301/302, so a browser following an axios write's redirect issues a GET rather than replaying the write against the page the admin is standing on. First entry in `app/Http/Kernel.php`'s global stack. 307/308 are left alone — those statuses exist to *say* "replay the method".

**Backend — Form Requests**
- [app/Http/Requests/Manage/Leads/StoreRequest.php](/app/Http/Requests/Manage/Leads/StoreRequest.php) — name / email / phone / source for the manual "New lead"; `withValidator()` previews through `LeadLinker::preview()` to refuse an email/phone that already belongs to a person or to staff (the same classifier the controller's write uses).
- [app/Http/Requests/Manage/Leads/UpdateRequest.php](/app/Http/Requests/Manage/Leads/UpdateRequest.php) — name / email / phone (status is not editable here).
- [app/Http/Requests/Manage/Leads/LeadQueryRequest.php](/app/Http/Requests/Manage/Leads/LeadQueryRequest.php) — search (via user) / status / source / funnel / date_from / date_to / **agent** / **fake** (Phase 2's `LeadQuality` resolver, whitelisted `whereRaw`).
- [app/Http/Requests/Manage/Leads/AssignRequest.php](/app/Http/Requests/Manage/Leads/AssignRequest.php) — set the lead's single account manager: one field `assignee` (a staff uuid, nullable = unassign) → `leads.assigned_admin_id`.
- [app/Http/Requests/Manage/Leads/ConvertRequest.php](/app/Http/Requests/Manage/Leads/ConvertRequest.php) — convert-to-member (membership + `price_paid` + `paid_at`).
- [app/Http/Requests/Manage/Leads/VerdictRequest.php](/app/Http/Requests/Manage/Leads/VerdictRequest.php) — the admin quality verdict: `kind` (agent|fake) + nullable `verdict` (0/1; null clears back to the AI guess).
- [app/Http/Requests/Manage/Leads/ImportRequest.php](/app/Http/Requests/Manage/Leads/ImportRequest.php) — the CSV/Excel upload (preview + apply).
- [app/Http/Requests/Manage/Leads/PurgeAllRequest.php](/app/Http/Requests/Manage/Leads/PurgeAllRequest.php) — **temporary**: the typed `DELETE ALL LEADS` confirmation.
- [app/Http/Requests/Manage/Leads/LeadActivityQueryRequest.php](/app/Http/Requests/Manage/Leads/LeadActivityQueryRequest.php) — the Activity tab's `category` / `type[]` / `q` / `date_from` / `date_to` filters (extends `ManageQueryRequest`, GUIDELINES §9/§14).
- (Sub-folders: `ZoomMeetings/StoreRequest.php`, `Discussion/StoreCommentRequest.php` + `StoreTopicRequest.php` + `UpdateCommentRequest.php` + `UpdateTopicRequest.php`, `ActionItems/StoreRequest.php` + `UpdateRequest.php` — the latter pair validates the image/video uploads: mimetypes + 50 MB, at most 6.)

**Backend — Jobs**
- [app/Jobs/Lead/EnrichLeadJob.php](/app/Jobs/Lead/EnrichLeadJob.php) — queued identity enrichment.
- [app/Jobs/Lead/PurgeAllLeadsJob.php](/app/Jobs/Lead/PurgeAllLeadsJob.php) — **temporary**: runs `LeadRepository::purgeAll()` off the request cycle.

**Tests**
- [tests/Feature/Lead/PurgeAllLeadsTest.php](/tests/Feature/Lead/PurgeAllLeadsTest.php) — **temporary**: proves the purge wipes the lead's whole footprint (rows + GCS objects) and never touches a staff / ex-staff account.
- [tests/Unit/Lead/LeadQualityTest.php](/tests/Unit/Lead/LeadQualityTest.php) — `LeadQuality`
  table-driven (every admin/AI combination) + PHP↔SQL agreement over a seeded DB matrix +
  `fakeLabel()`/`agentLabel()` + the `$table` qualifier override.
- [tests/Feature/Lead/LeadQualityFilterTest.php](/tests/Feature/Lead/LeadQualityFilterTest.php) —
  the Leads index filter/counts agreement, visibility scoping, export parity (filter honoured,
  `per_page` never honoured, resolved labels incl. the "Fake?" hedge), and the export's N+1 guard
  (query count stays flat as the export's lead count grows).

**Backend — Contact source (shared)**
- [src/People/User.php](/src/People/User.php) — the linked account (email, role).
- [src/People/UserProfile.php](/src/People/UserProfile.php) — `full_name`, `phone` (digit-normalizing mutator).
- [src/People/Repositories/UserRepository.php](/src/People/Repositories/UserRepository.php) — used to update the profile.

**Frontend (Vue)**
- [resources/js/Pages/Manage/Leads/Index.vue](/resources/js/Pages/Manage/Leads/Index.vue) — list, filters (incl. Agent / Fake lead), inline status, pagination; `<QualityTagChips>` in `#cell-name`.
- [resources/js/Components/Leads/QualityTagMeta.js](/resources/js/Components/Leads/QualityTagMeta.js) —
  the single label/colour/icon source (mirrors `LeadQuality::AGENT_STATES`/`FAKE_STATES`);
  `qualityStates(insight)` (the `insight === null` default contract); `agentLabel()`/`fakeLabel()`
  (the "Fake?" hedge); `matchesQualityOption(insight, option)` (the roster triage-bucket
  definition, shared by both rosters' different filter idioms).
- [resources/js/Components/Leads/QualityTagChips.vue](/resources/js/Components/Leads/QualityTagChips.vue) —
  the shared display-only tag chips (Leads index, VSL roster, Registrations roster).
- [resources/js/Components/LeadQualityTags.vue](/resources/js/Components/LeadQualityTags.vue) —
  the Show page / `LeadDetailModal` identity-header tags **+ the admin verdict-setting dropdown**
  (the one write surface — the two chip components above are display-only); consumes
  `QualityTagMeta.js` for its label/colour source.
- [resources/js/composables/useLeadTabs.js](/resources/js/composables/useLeadTabs.js) — **the one definition of the tab tree** (outer strip + the Sales / Channel / Portal sub-strips, their counts and gates) plus `LEGACY_TAB_MAP` / `remapLeadTabs()`. Shared by `Show.vue` and `LeadDetailModal.vue` so the two can never drift apart again; locked by [useLeadTabs.test.js](/resources/js/composables/useLeadTabs.test.js).
- [resources/js/Pages/Manage/Leads/Show.vue](/resources/js/Pages/Manage/Leads/Show.vue) — detail page: identity header + `ShowTabs` (Intelligence / **Sales** / **Channel** / **Property Portal** / Activity / Discussion / (Physical Events)), the three grouped tabs nesting their own strip on `?stab=` / `?ctab=` / `?ptab=` and seeding legacy deep-links through `remapLeadTabs()`. Owns what the modal does not: Delete, the account-manager **Change** button, and the lazy `?callDetail` / `?f2fDetail` / `?zoomDetail` recording loads.
- [resources/js/Components/LeadDetailModal.vue](/resources/js/Components/LeadDetailModal.vue) — the same tree rendered **read-only** in a full-screen modal, mounted once in `ManageLayout` and opened from anywhere via `useLeadModal().openLead(uuid, { tab, stab, ctab, ptab })`. One `fetch` of `GET /manage/leads/{uuid}/quick` replaces the Inertia props; a `provide('leadReadonly', true)` tells every reused tab to hide its write controls and skip its own partial reloads (which would hit the **host** page). Every strip runs `:sync-url="false"` — the address bar belongs to whatever page the modal floats over. **Discussion is the one interactive tab**: it writes via axios (`write-mode="modal"`) and emits `refresh`, which re-pulls only `?section=discussion`. **"Open full page" is a plain `<a target="_blank" rel="noopener">`, not an Inertia `<Link>`** — a client-side visit has no meaning for a new tab — and it deliberately does **not** call `closeLead()`: the click never navigates this tab, so closing the modal would mean switching back to find it, the scroll position and any Discussion draft gone.
  - **Edit lives in the header too** (added 2026-08-06, `manage-leads` only). It opens the SAME `LeadFormModal` as the Leads index row action and the Show page, passed `host="modal"`. The save is an **ordinary Inertia visit kept in place** — three options, and each earns its keep:
    1. **`preserveState: true`** — the visit's whole point is that the host page's controller re-runs, so its table, counters and props come back fresh *on every screen that can open the modal, with no per-host wiring.* Without it Inertia stamps a new component key, the page component (and with it `ManageLayout` → this modal → the payload it fetched) **remounts**, and the modal is left open and **blank** — `activeUuid` never changed, so its `watch` never re-fires and it never reloads itself.
    2. **`keepOpenDuringVisit()`** ([useLeadModal](/resources/js/composables/useLeadModal.js)) — stands the close-on-navigate rule down for that one visit. Mostly belt-and-braces: Inertia does **not** fire `navigate` when the destination equals the current URL (`replace` wins), and `redirect()->back()` normally returns the same URL. It matters when `back()` resolves to the URL *without* `?lead=` (that param is added by `history.replaceState`, so a stale session `_previous.url` has no memory of it). Always release it from `onFinish`.
    3. **`@saved` → `?section=lead`** — the visit refreshed the HOST; the modal's own payload came from a separate `fetch` and cannot ride it, so it re-reads just the lead row and patches `data.lead` in place, keeping the open tab, the scroll position and any Discussion draft.
    - The **confirmed merge** deliberately does none of this: the server collapses the two accounts and redirects to the leads index, because the lead on screen may be the side that was absorbed — so the ordinary close-on-navigate rule is exactly right.
  - **⚠️ Do not "solve" the modal-survives-a-save problem by writing over axios.** The first build did, and dodging Inertia dodged everything Inertia carries: `flash()` was written to the session and then **consumed by the redirect axios silently follows**, so no confirmation ever appeared; and the host page's props were never re-read, so its table kept showing the old name until a manual browser refresh. The save worked and the screen said nothing. The fix is to keep the visit and teach the modal to survive it, not to leave the pipeline.
  - **⚠️ An axios write that redirects is a loaded gun — the safety now lives in middleware.** `back()` answers **302**, and a browser follows an XHR redirect *by itself*; per the fetch spec it rewrites the method to GET **only for a POST**, so a **PUT or DELETE is replayed verbatim** against `back()`'s target — which is the page the modal happens to be floating over. On **2026-08-12** that deleted a live funnel (94 leads, ads running): the modal's `DELETE /manage/leads/{id}/discussion/topics/{uuid}` redirected 302 to `/manage/events/funnels/{uuid}?tab=vsl-leads`, the browser replayed the DELETE there, and it matched `DELETE events/funnels/{id}`. The same shape reached `DELETE sales-projects/{id}` (the project Leads tab) and `DELETE leads/{id}` (the Lead Show page). Inertia's own middleware already performs the cure — downgrade 302 → **303 See Other**, the one status that MAKES a browser follow with GET — but it returns early for any request without the `X-Inertia` header, so it protected Inertia visits and left every axios write exposed. [`App\Http\Middleware\PreventRedirectMethodReplay`](/app/Http/Middleware/PreventRedirectMethodReplay.php) now applies that rewrite **globally and unconditionally** for PUT / PATCH / DELETE, first in the kernel stack so nothing can undo it, and [`tests/Feature/Security/RedirectMethodReplayTest.php`](/tests/Feature/Security/RedirectMethodReplayTest.php) pins it. **The guard has to be a status assertion**: a 302 on a DELETE looks entirely ordinary from the server, the damage happens in the browser afterwards, and nothing in a request log says so — which is also why `assertRedirect()`, not `assertStatus(302)`, is the assertion to reach for on a write.
  - **Both headers badge the proof state of each contact key** ([Components/VerifiedBadge.vue](/resources/js/Components/VerifiedBadge.vue), fed by `email_verified_at` / `phone_verified_at` on the lead row `transform()` already emitted for the edit form). Sign-in mails/texts the code **to** the key, so "Not verified" is not decoration — it is the reason that person cannot sign in, worth seeing before an admin tells them to try.
- [resources/js/Pages/Manage/Leads/Partials/Tabs/](/resources/js/Pages/Manage/Leads/Partials/Tabs/) — one self-contained component per tab body, rendered by **both** surfaces above. The Property Portal tab nests Wealth Planning + Property Analyses + the read-only **`AiConversationsTab.vue`** (full chat thread) and **`AiDebatesTab.vue`** (the panel board + verdict), plus **`ConciergeTab.vue`**; **`ActivityTab.vue`** is a top-level tab. **`ActionItemsPanel.vue`** is the Discussion tab's sticky right rail (composer with attach + assignee dropdown, item cards with inline image/video, done toggle, author-owned edit) — it mirrors `DiscussionTab`'s dual write transport (`inertia` on the Show page, axios + `refresh` in the modal).
  - **`ActivityTab.vue`** — summary cards + a category-chip filter strip + search / date range / type multi-select, each round-tripping (debounced ~300ms on search) to `GET {id}/activity`; the timeline renders a category tag, whitelisted `meta` chips and the actor (`by <admin>`) per row, and colours every row via [`utils/activityColors.js`](/resources/js/utils/activityColors.js) — a single dot/chip colour map covering every colour `ActivityLog::TYPES` / `::CATEGORIES` can emit (a local, partial copy of this map used to silently render new colours grey). Read-only mode (`leadReadonly` inject) drops the filter bar and fetch entirely, showing only the summary cards + the capped rows already on the payload — see the [ActivityLogger handbook](/docs/modules_handbook/shared/activity-log/readMe.md) for the endpoint + row-mapper contract.
- [resources/js/Components/AssignLeadManagerModal.vue](/resources/js/Components/AssignLeadManagerModal.vue) — the shared single-select account-manager picker, posts `assignee` to `manage.leads.assign`. Opened from the Leads list row action and the Lead Show identity header.
- [resources/js/Pages/Manage/Leads/Partials/MergeLeadPickerModal.vue](/resources/js/Pages/Manage/Leads/Partials/MergeLeadPickerModal.vue) — **"Same person, two leads?"** (2026-08-07). A row action on the index and a **Merge duplicate** header button on Show, both `isAdmin`-only. It exists because every automatic filer keys off a contact-key *collision*, and two records for one human very often share **no key at all** — so nothing but a human can raise them. Picks the other lead through the existing `manage.leads.search` typeahead, `POST`s `manage/leads/{id}/merge-request` ([`proposeMerge`](/app/Http/Controllers/Manage/Leads/LeadsController.php)) which **only files the pair**, then mounts the merge queue's own [`MergeReviewModal`](/resources/js/Pages/Manage/People/MergeRequests/Partials/MergeReviewModal.vue) on that pair id — so the merge itself runs through one UI with one set of guards, never a second path. Full rules + the survivor read-back: [Merge Requests](/docs/modules_handbook/manage/people/merge-requests/readMe.md).
- [resources/js/composables/useLeadModal.js](/resources/js/composables/useLeadModal.js) — the singleton driving the modal (`openLead` / `closeLead`, the `?lead=` param, the close-on-navigate rule) plus `keepOpenDuringVisit()`, the bracket that lets a save made inside the modal refresh its host page in place.
- [resources/js/Layouts/ManageLayout.vue](/resources/js/Layouts/ManageLayout.vue) — sidebar nav entry. `/manage/lead-distribution` is a prefix of the pinned **Setting** entry, not of Leads: the routing tables are configuration, so the footer lights up there.
- **Distribution Setting** — a shortcut button on this page opening `/manage/lead-distribution`, the **Lead Distribution** tab of the Setting hub ([Components/SettingTabs.vue](/resources/js/Components/SettingTabs.vue)). That tab fronts the strip **Lead Tiers / Salesperson Tiers / Rules / [Groups](/docs/modules_handbook/manage/people/groups/readMe.md)** ([Components/SectionTabs.vue](/resources/js/Components/SectionTabs.vue), section `lead-distribution`). The first three are panels on one page selected by `?tab=`; Groups is its own page. Every page in the section mounts `SettingTabs` above the strip, so the hub is the way back — there is no separate Back to Leads header.

**Migrations**
- [database/migrations/2020_05_25_042762_create_leads_table.php](/database/migrations/2020_05_25_042762_create_leads_table.php) — `user_id` (unique) + status (attribution columns dropped by the move migration below).
- [database/migrations/2026_07_06_000001_add_assigned_admin_to_leads_table.php](/database/migrations/2026_07_06_000001_add_assigned_admin_to_leads_table.php) — `assigned_admin_id` (nullable, indexed) — the lead's single **account manager** (and the lead-ownership key for `LeadVisibility`). Backfilled from the old subscription-managers pivot by `2026_07_18_000001_backfill_lead_account_manager_from_subscriptions` (which `2026_07_18_000002_drop_member_subscription_managers_table` then drops).
- [database/migrations/2026_07_21_000001_add_properties_owned_to_leads_table.php](/database/migrations/2026_07_21_000001_add_properties_owned_to_leads_table.php) — `properties_owned` (`unsignedTinyInteger`, nullable, indexed): how many properties the person owns. A **declared** fact (captured from the person / a legacy CRM export), which is why it lives on `leads` and not on `lead_enrichments` — the enrichment row is regenerated by `EnrichLeadJob` (`updateOrCreate`) and would eventually overwrite it. ⚠️ `NULL` (never asked) and `0` (owns none) are **different** — read it with a null check, never a falsy check. Written on create via `LeadRepository::create()` (`lead.properties_owned`) or later via `LeadRepository::setPropertiesOwned()`.
- `2026_06_18_000002_create_lead_funnels_table` — the per-registration table: `lead_id` + `event_funnel_id` + `registered_at` + attribution (utm_*, fbclid, campaign/adset/ad ids, placement, referrer, landing_url, ip, user_agent), `unique(lead_id, event_funnel_id)`.
- `2026_06_18_000003_move_attribution_from_leads_table` — backfills `leads` → `lead_funnels`, then drops the attribution columns from `leads`.
- [database/migrations/2026_08_03_400001_create_lead_action_items_tables.php](/database/migrations/2026_08_03_400001_create_lead_action_items_tables.php) — `lead_action_items` (key model: uuid + blame + soft delete; `status` defaulting to `STATUS_OPEN`, `done_at` / `done_by`) + the `lead_action_item_assignees` child pivot (`action_item_id` + `user_id`, one row per assigned admin — no uuid, hard-deleted with its item).
- [database/migrations/2026_07_29_200001_add_meta_browser_ids_to_lead_funnels.php](/database/migrations/2026_07_29_200001_add_meta_browser_ids_to_lead_funnels.php) — `fbp` + `fbc` (nullable strings, after `fbclid`): the visitor's **Meta browser identifiers**, captured at registration. `_fbp` is written by the Pixel; `_fbc` is the click id, rebuilt from the `fbclid` in Meta's `fb.1.<ms>.<fbclid>` shape when the cookie is absent (`LandingController::clickId`). They sit on the **registration row, not the lead**, for the same reason `fbclid` / `campaign_id` do — they belong to **one ad touch**, so a person who registers again through a different ad gets a different pair. ⚠️ They are stored, not merely forwarded: a **Purchase weeks later is confirmed by a gateway webhook with no browser and no cookies**, so without them Meta can only match on hashed email/phone and match quality (hence ad optimisation and reported ROAS) drops.

**Seeder**
- [database/seeds/LeadsSeeder.php](/database/seeds/LeadsSeeder.php) — sample leads, each with a linked Non-Member account + a default-funnel registration carrying its attribution.

**Routes**
- [routes/web.php](/routes/web.php) — `manage.leads.*` group.
